diff --git a/assets/webui/index.html b/assets/webui/index.html index 43a7c4b..f357bf6 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -43,13 +43,14 @@ -
+
+ 导出 - + + + + @@ -434,21 +435,23 @@
- +
+ +
- 显示 + 预览
- +
详情 -
显示
SVG
编辑
SVG
编译
图片
SVG
PDF
EPS
+
显示
SVG
编辑
SVG
字体
几何
编译
图片
SVG
PDF
EPS
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 73e78fa..19d4dfb 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -620,7 +620,12 @@ button:not(:disabled):hover { } .dpi-select { - width: 54px; + width: 79px; +} + +.tool-label { + color: var(--text-muted); + white-space: nowrap; } .form-dialog { @@ -985,6 +990,8 @@ body[data-hide-meta] .meta-panel { } .render-panel { + --render-controls-width: 166px; + --render-modes-width: 79px; position: relative; margin-bottom: 12px; padding-bottom: 12px; @@ -992,8 +999,7 @@ body[data-hide-meta] .meta-panel { } .render-panel select { - width: 180px; - max-width: calc(100% - 76px); + width: 100%; height: 28px; font-size: 12px; } @@ -1012,10 +1018,10 @@ body[data-hide-meta] .meta-panel { } .render-controls { - display: flex; + display: grid; + grid-template-columns: var(--render-modes-width) minmax(0, 1fr); align-items: center; - width: 180px; - max-width: calc(100% - 76px); + width: var(--render-controls-width); gap: 8px; } @@ -1025,7 +1031,7 @@ body[data-hide-meta] .meta-panel { } .render-modes .small-button { - width: 36px; + width: 40px; height: 28px; padding: 0; } @@ -1041,13 +1047,6 @@ body[data-hide-meta] .meta-panel { color: var(--accent-strong); } -.render-controls select { - flex: 1; - width: 0; - min-width: 0; - max-width: 100%; -} - .render-support { font-size: 12px; color: var(--text-muted); @@ -1056,8 +1055,9 @@ body[data-hide-meta] .meta-panel { .render-support summary { position: absolute; top: 0; - left: 32px; - width: fit-content; + right: 0; + width: calc(var(--render-controls-width) - var(--render-modes-width) - 8px); + text-align: center; line-height: 28px; cursor: pointer; color: var(--accent-strong); @@ -2579,6 +2579,6 @@ button.outline-link:hover { } .dpi-select { - width: 48px; + width: 79px; } } diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 90d6ce2..6177ae5 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -332,6 +332,8 @@ const el = { renderPDF: document.querySelector("#renderPDF"), renderEPS: document.querySelector("#renderEPS"), renderCompiler: document.querySelector("#renderCompiler"), + renderFonts: document.querySelector("#renderFonts"), + renderGeometry: document.querySelector("#renderGeometry"), offlineStatus: document.querySelector("#offlineStatus"), refreshAppButton: document.querySelector("#refreshAppButton"), metaFile: document.querySelector("#metaFile"), @@ -3140,6 +3142,7 @@ function updateRenderBackend() { el.renderDPI.value = String(state.renderDPI); el.renderBackend.disabled = !state.ready || state.exporting || document.body.hasAttribute("aria-busy"); el.renderDPI.disabled = el.renderBackend.disabled || displayMode() === "svg"; + el.renderDPI.title = displayMode() === "svg" ? "矢量预览无需DPI" : "预览DPI(仅位图)"; el.renderVectorButton.disabled = el.renderRasterButton.disabled = el.renderBackend.disabled || state.editing; el.renderVectorButton.setAttribute("aria-pressed", String(displayMode() === "svg")); el.renderRasterButton.setAttribute("aria-pressed", String(displayMode() === "raster")); @@ -3152,6 +3155,8 @@ function updateRenderBackend() { el.renderPDF.textContent = renderBackendLabel(backend?.pdf); el.renderEPS.textContent = renderBackendLabel(backend?.eps); el.renderCompiler.textContent = renderBackendLabel(backend?.compiler); + el.renderFonts.textContent = renderBackendLabel(backend?.fonts); + el.renderGeometry.textContent = renderBackendLabel(backend?.geometry); } async function refreshRenderBackend(anchor = scaleAnchor(0)) { @@ -6348,6 +6353,7 @@ function exportFormatUsesDPI(value) { function updateDPIControl() { el.imageDPI.disabled = state.exporting || !state.doc || !exportFormatUsesDPI(el.exportFormat.value); + el.imageDPI.title = exportFormatUsesDPI(el.exportFormat.value) ? "导出DPI(仅PNG/JPG)" : "当前格式无需DPI"; } function currentImageDPI() { diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 999d710..f0f09d2 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -29,6 +29,7 @@ import ( "io" "math" "path" + "reflect" "slices" "strconv" "strings" @@ -374,8 +375,8 @@ func renderPage(args []js.Value) (any, error) { if err != nil { return nil, err } - if currentEditor != nil { - currentEditor.SetPageCompiler(currentSession.Renderer.Backends().Compiler) + if currentEditor != nil && !reflect.DeepEqual(currentEditor.Backends(), currentSession.Renderer.Backends()) { + currentEditor.SetRenderBackends(currentSession.Renderer.Backends()) } text, err := currentSession.PageText(args[0].Int()) if err != nil { @@ -844,7 +845,7 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { if kind != "" && capability.Update { item["shape"] = string(kind) item["geometry"] = map[string]any{"x": geometry.X, "y": geometry.Y, "width": geometry.W, "height": geometry.H} - } else if outline, err := path.Outline(); err == nil { + } else if outline, err := currentSession.Renderer.PathOutline(path); err == nil { item["outline"] = outline } if kind == "" && capability.Update { @@ -1162,7 +1163,7 @@ func writeAnnotation(args []js.Value) (any, error) { } if options.Kind == "watermark" { if watermarkBounds.W == 0 { - watermarkBounds, err = measureWatermark(annotation.Appearance.Objects[0], fontData) + watermarkBounds, err = measureWatermark(annotation.Appearance.Objects[0], fontData, currentEditor.Backends()) if err != nil { return err } @@ -1195,9 +1196,9 @@ func writeAnnotation(args []js.Value) (any, error) { } // measureWatermark 复用标准渲染度量水印,临时文档保留尚未提交的字体引用 -// 入参: object 水印图元, fontData 字体字节 +// 入参: object 水印图元, fontData 字体字节, backends 当前后端配置 // 返回: ofdgo.Box 实际绘制范围, error 错误信息 -func measureWatermark(object ofdgo.GraphicObject, fontData []byte) (ofdgo.Box, error) { +func measureWatermark(object ofdgo.GraphicObject, fontData []byte, backends ofdgo.RenderBackends) (ofdgo.Box, error) { if object.Type == "ImageObject" { return ofdgo.NewMatrix(object.ImageObject.CTM).TransformBox(ofdgo.Box{W: 1, H: 1}), nil } @@ -1221,7 +1222,7 @@ func measureWatermark(object ofdgo.GraphicObject, fontData []byte) (ofdgo.Box, e if _, err := reader.PageContentByIndex(0); err != nil { return ofdgo.Box{}, err } - bounds, err := ofdgo.NewRenderer(reader).ObjectBounds(ofdgo.GraphicObject{Type: "CompositeObject", CompositeGraphicUnit: ofdgo.CompositeGraphicUnit{ + bounds, err := ofdgo.NewRenderer(reader, ofdgo.WithRenderBackends(backends)).ObjectBounds(ofdgo.GraphicObject{Type: "CompositeObject", CompositeGraphicUnit: ofdgo.CompositeGraphicUnit{ Boundary: object.TextObject.Boundary, Objects: []ofdgo.GraphicObject{object}, }}, "") if err != nil { @@ -2781,7 +2782,7 @@ func editDocument(args []js.Value) (any, error) { if currentSession.fontFS != nil { editor.SetFontFS(currentSession.fontFS) } - editor.SetPageCompiler(currentSession.Renderer.Backends().Compiler) + editor.SetRenderBackends(currentSession.Renderer.Backends()) currentEditor = editor currentSession.editing = true copiedObjects = nil @@ -2811,7 +2812,7 @@ func previewEditor(editor *ofdgo.Editor, annotations bool) (editorInfo, error) { } _ = currentSession.Close() } - editor.SetPageCompiler(session.Renderer.Backends().Compiler) + editor.SetRenderBackends(session.Renderer.Backends()) currentSession = session session.editing = true if currentEditor != editor { diff --git a/internal/webui/webui.go b/internal/webui/webui.go index 9b2edb8..3410982 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -19,6 +19,7 @@ import ( "fmt" "io" "path" + "reflect" "strings" "time" @@ -536,7 +537,11 @@ func (s *Session) SetRenderBackend(name string) error { if err != nil { return err } - if s.Renderer.Backends().Info().Compiler != backends.Info().Compiler { + if reflect.DeepEqual(s.Renderer.Backends(), backends) { + return nil + } + previous, next := s.Renderer.Backends().Info(), backends.Info() + if previous.Compiler != next.Compiler || previous.Fonts != next.Fonts || previous.Geometry != next.Geometry { clear(s.textCache) } ofdgo.WithRenderBackends(backends)(s.Renderer) diff --git a/ofdgo.go b/ofdgo.go index cb30dcb..2fba2fe 100644 --- a/ofdgo.go +++ b/ofdgo.go @@ -79,7 +79,6 @@ func NewRenderer(reader *Reader, opts ...RendererOption) *Renderer { for _, opt := range opts { opt(r) } - r.initCommon() return r } diff --git a/ofdgo_action.go b/ofdgo_action.go index 2bccf01..994b3df 100644 --- a/ofdgo_action.go +++ b/ofdgo_action.go @@ -18,8 +18,6 @@ import ( "encoding/xml" "net/url" "strconv" - - "github.com/tdewolff/canvas" ) // Action 动作 @@ -346,7 +344,11 @@ func (r *Renderer) appendGraphicActionSources(sources []actionSource, object Gra source.Box.W, source.Box.H = box.W, box.H } if box.W > 0 && box.H > 0 && !axisAlignedMatrix(placement) { - source.Path = canvas.Rectangle(box.W, box.H).Transform(actionCanvasMatrix(placement)).ToSVG() + path := geometryRectangle(Box{W: box.W, H: box.H}) + for i := range path { + path[i].End.X, path[i].End.Y = placement.Transform(path[i].End.X, path[i].End.Y) + } + source.Path, _ = path.SVG() } sources = append(sources, source) } @@ -366,93 +368,27 @@ func (r *Renderer) appendGraphicActionSources(sources []actionSource, object Gra return sources } -// actionCanvasMatrix 转换页面坐标矩阵,不翻转Y轴 -// 入参: matrix 页面变换 -// 返回: canvas.Matrix 路径变换 -func actionCanvasMatrix(matrix Matrix) canvas.Matrix { - return canvas.Matrix{{matrix.a, matrix.c, matrix.e}, {matrix.b, matrix.d, matrix.f}} -} - -// actionRegionPath 解析复杂点击区域并转换为页面坐标 -// 入参: region 动作区域, matrix 坐标变换 -// 返回: *canvas.Path 点击路径 -func actionRegionPath(region *Region, matrix Matrix) *canvas.Path { - path := &canvas.Path{} - for _, area := range region.Area { - start := parseFloats(area.Start) - if len(start) != 2 { - continue - } - part := &canvas.Path{} - part.MoveTo(start[0], start[1]) - for _, command := range area.Command { - p1, p2, p3 := parseFloats(command.Point1), parseFloats(command.Point2), parseFloats(command.Point3) - switch command.Type { - case "Move": - if len(p1) == 2 { - part.Close() - part.MoveTo(p1[0], p1[1]) - } - case "Line": - if len(p1) == 2 { - part.LineTo(p1[0], p1[1]) - } - case "QuadraticBezier": - if len(p1) == 2 && len(p2) == 2 { - part.QuadTo(p1[0], p1[1], p2[0], p2[1]) - } - case "CubicBezier": - if len(p3) == 2 { - first, second := part.Pos(), canvas.Point{X: p3[0], Y: p3[1]} - if len(p1) == 2 { - first = canvas.Point{X: p1[0], Y: p1[1]} - } - if len(p2) == 2 { - second = canvas.Point{X: p2[0], Y: p2[1]} - } - part.CubeTo(first.X, first.Y, second.X, second.Y, p3[0], p3[1]) - } - case "Arc": - size, end := parseFloats(command.EllipseSize), parseFloats(command.EndPoint) - angle, err := strconv.ParseFloat(command.RotationAngle, 64) - large, largeErr := strconv.ParseBool(command.LargeArc) - sweep, sweepErr := strconv.ParseBool(command.SweepDirection) - if len(end) == 2 && err == nil && largeErr == nil && sweepErr == nil { - rx, ry := 0.0, 0.0 - if len(size) > 0 { - rx, ry = size[0], size[0] - } - if len(size) > 1 { - ry = size[1] - } - part.ArcTo(rx, ry, angle, large, sweep, end[0], end[1]) - } - case "Close": - part.Close() - } - } - part.Close() - path = path.Append(part) - } - if canvas.Equal(matrix.a*matrix.d-matrix.b*matrix.c, 0) { - path = path.ReplaceArcs() - } - return path.Transform(actionCanvasMatrix(matrix)) -} - // actionLinkRegion 获取动作点击区域 // 入参: source 动作来源, action 动作 -// 返回: Box 外接矩形, string 精确路径 -func actionLinkRegion(source actionSource, action Action) (Box, string) { +// 返回: Box 外接矩形, string 精确路径, error 几何错误 +func (r *Renderer) actionLinkRegion(source actionSource, action Action) (Box, string, error) { if action.Region == nil { - return source.Box, source.Path + return source.Box, source.Path, nil } - path := actionRegionPath(action.Region, source.Matrix) - if path.Empty() { - return Box{}, "" + geometry, err := r.Geometry() + if err != nil { + return Box{}, "", err } - box := path.Bounds() - return Box{X: box.X0, Y: box.Y0, W: box.W(), H: box.H()}, path.ToSVG() + path, err := geometry.Region(action.Region, source.Matrix) + if err != nil { + return Box{}, "", err + } + box, err := geometry.Bounds(path) + if err != nil { + return Box{}, "", err + } + outline, err := path.SVG() + return box, outline, err } // resolveActionURI 解析URI动作地址 @@ -472,3 +408,22 @@ func resolveActionURI(action URI) string { } return base.ResolveReference(target).String() } + +// outlineDest 获取大纲跳转目标 +// 入参: outline 大纲节点, bookmarks 书签 +// 返回: *Dest 跳转目标 +func outlineDest(outline OutlineElem, bookmarks map[string]Dest) *Dest { + for _, action := range outline.Actions { + if action.Goto != nil { + if dest := gotoDest(action.Goto, bookmarks); dest != nil { + return dest + } + } + } + for _, child := range outline.OutlineElem { + if dest := outlineDest(child, bookmarks); dest != nil { + return dest + } + } + return nil +} diff --git a/ofdgo_backend.go b/ofdgo_backend.go index 51a2246..f42049e 100644 --- a/ofdgo_backend.go +++ b/ofdgo_backend.go @@ -104,6 +104,8 @@ type EPSBackend interface { // RenderBackends 按职责组合后端,nil表示明确禁用,不隐式回退 // 实例及其Renderer需串行使用,第三方实现无需引用任何内置绘图库 type RenderBackends struct { + Fonts FontBackend + Geometry GeometryBackend Compiler PageCompiler Raster RasterBackend SVG SVGBackend @@ -113,6 +115,8 @@ type RenderBackends struct { // BackendInfo 描述各项能力实际使用的后端,空标识表示未配置 type BackendInfo struct { + Fonts string `json:"fonts"` + Geometry string `json:"geometry"` Compiler string `json:"compiler"` Raster string `json:"raster"` SVG string `json:"svg"` @@ -150,13 +154,13 @@ func RenderBackendInfos() map[string]BackendInfo { // 返回: RenderBackends 默认后端组合 func defaultRenderBackends() RenderBackends { backend := CanvasBackend{} - return RenderBackends{Compiler: backend, Raster: backend, SVG: backend, PDF: backend, EPS: backend} + return RenderBackends{Fonts: backend, Geometry: backend, Compiler: backend, Raster: backend, SVG: backend, PDF: backend, EPS: backend} } // Info 返回配置中各能力的提供者,不依赖WebUI推断 // 返回: BackendInfo 后端能力 func (b RenderBackends) Info() BackendInfo { - return BackendInfo{Compiler: backendName(b.Compiler), Raster: backendName(b.Raster), SVG: backendName(b.SVG), PDF: backendName(b.PDF), EPS: backendName(b.EPS)} + return BackendInfo{Fonts: backendName(b.Fonts), Geometry: backendName(b.Geometry), Compiler: backendName(b.Compiler), Raster: backendName(b.Raster), SVG: backendName(b.SVG), PDF: backendName(b.PDF), EPS: backendName(b.EPS)} } // backendName 获取已配置的后端标识 @@ -173,7 +177,10 @@ func backendName(backend Backend) string { // 入参: backends 后端组合 // 返回: RendererOption 渲染选项 func WithRenderBackends(backends RenderBackends) RendererOption { - return func(r *Renderer) { r.backends = backends } + return func(r *Renderer) { + r.backends = backends + r.resetFontCache() + } } // Backends 返回当前后端配置副本,可修改后通过WithRenderBackends应用 diff --git a/ofdgo_drawparam.go b/ofdgo_drawparam.go new file mode 100644 index 0000000..439d06b --- /dev/null +++ b/ofdgo_drawparam.go @@ -0,0 +1,123 @@ +// 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 + +const defaultPathLineWidth = 0.353 +const defaultMiterLimit = 3.528 + +// mergeGraphicObjectAlpha 合并图形对象透明度 +// 入参: obj 图形对象, alpha 父级透明度 +// 返回: GraphicObject 合并后的图形对象 +func mergeGraphicObjectAlpha(obj GraphicObject, alpha *int) GraphicObject { + if alpha == nil { + return obj + } + switch obj.Type { + case "TextObject": + obj.TextObject.Alpha = mergeAlpha(obj.TextObject.Alpha, alpha) + case "PathObject": + obj.PathObject.Alpha = mergeAlpha(obj.PathObject.Alpha, alpha) + case "ImageObject": + obj.ImageObject.Alpha = mergeAlpha(obj.ImageObject.Alpha, alpha) + case "CompositeGraphicUnit", "CompositeObject": + obj.CompositeGraphicUnit.Alpha = mergeAlpha(obj.CompositeGraphicUnit.Alpha, alpha) + } + return obj +} + +// drawParamDefaults 合并绘制参数默认样式 +// 入参: id 绘制参数ID, defaults 默认绘制参数 +// 返回: *DrawParam 合并后的绘制参数 +func (r *Renderer) drawParamDefaults(id string, defaults *DrawParam) *DrawParam { + if id == "" { + return defaults + } + dp := r.getDrawParam(id, nil) + if dp == nil { + return defaults + } + if defaults == nil { + return dp + } + return mergeDrawParam(*defaults, dp) +} + +// mergeDrawParam 合并绘制参数属性 +// 入参: base 基础绘制参数, dp 覆盖绘制参数 +// 返回: *DrawParam 合并后的绘制参数 +func mergeDrawParam(base DrawParam, dp *DrawParam) *DrawParam { + if dp.LineWidth > 0 { + base.LineWidth = dp.LineWidth + } + if dp.Join != "" { + base.Join = dp.Join + } + if dp.Cap != "" { + base.Cap = dp.Cap + } + if dp.DashPattern != "" || dp.dashPatternSet { + base.DashPattern = dp.DashPattern + base.dashPatternSet = dp.dashPatternSet + } + if dp.DashOffset != nil { + base.DashOffset = dp.DashOffset + } + if dp.MiterLimit > 0 { + base.MiterLimit = dp.MiterLimit + } + if dp.FillColor != nil { + base.FillColor = dp.FillColor + } + if dp.StrokeColor != nil { + base.StrokeColor = dp.StrokeColor + } + if dp.Font != "" { + base.Font = dp.Font + } + if dp.Size > 0 { + base.Size = dp.Size + } + if dp.Weight > 0 { + base.Weight = dp.Weight + } + if dp.Italic { + base.Italic = dp.Italic + } + return &base +} + +// getDrawParam 获取绘制参数逻辑 +// 入参: id 参数ID, visited 访问记录 +// 返回: *DrawParam 绘制参数 +func (r *Renderer) getDrawParam(id string, visited map[string]bool) *DrawParam { + if visited == nil { + visited = make(map[string]bool) + } + if visited[id] { + return nil + } + visited[id] = true + if dp, ok := r.DrawParams[id]; ok { + if dp.Relative != "" { + base := r.getDrawParam(dp.Relative, visited) + if base == nil { + return dp + } + return mergeDrawParam(*base, dp) + } + return dp + } + return nil +} diff --git a/ofdgo_editor.go b/ofdgo_editor.go index 1c96107..788d475 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -48,7 +48,8 @@ type Editor struct { fonts map[string]*font.SFNT fontFS []fs.FS fontRenderer *Renderer - compiler PageCompiler + fontMetrics map[string]FontMetrics + backends RenderBackends images map[string]image.Point resourceID map[editorResourceKey]string maxID int @@ -67,25 +68,38 @@ type Editor struct { func (e *Editor) SetFontFS(fsys ...fs.FS) { e.fontFS = slices.Clone(fsys) e.fontRenderer = nil + e.fontMetrics = make(map[string]FontMetrics) } // SetPageCompiler 设置编辑对象度量使用的页面编译器,不改变文档或撤销历史 // 与Renderer使用相同实例可统一显示、搜索和选区,nil禁用度量,不隐式回退 // 入参: compiler 页面编译器 func (e *Editor) SetPageCompiler(compiler PageCompiler) { - e.compiler = compiler + e.backends.Compiler = compiler if e.fontRenderer != nil { e.fontRenderer.backends.Compiler = compiler } } -// newRenderer 使用编辑器字体来源和页面编译器创建资源度量器 +// SetRenderBackends 设置完整后端组合并重置字体度量,不改变文档或撤销历史 +// 入参: backends 后端组合,未配置的能力不会沿用默认实现 +func (e *Editor) SetRenderBackends(backends RenderBackends) { + e.backends = backends + e.fontRenderer = nil + e.fontMetrics = make(map[string]FontMetrics) +} + +// Backends 返回编辑器后端配置副本 +// 返回: RenderBackends 后端组合 +func (e *Editor) Backends() RenderBackends { + return e.backends +} + +// newRenderer 使用编辑器字体来源和完整后端配置创建资源度量器 // 入参: reader 当前文档快照 // 返回: *Renderer 度量器 func (e *Editor) newRenderer(reader *Reader) *Renderer { - backends := defaultRenderBackends() - backends.Compiler = e.compiler - return NewRenderer(reader, WithFontFS(e.fontFS...), WithRenderBackends(backends)) + return NewRenderer(reader, WithFontFS(e.fontFS...), WithRenderBackends(e.backends)) } // editorResource 文档内嵌资源 @@ -130,10 +144,11 @@ func NewEditor() *Editor { DocID: hex.EncodeToString(id[:]), CreationDate: time.Now().Format("2006-01-02"), }, - fonts: make(map[string]*font.SFNT), - images: make(map[string]image.Point), - resourceID: make(map[editorResourceKey]string), - compiler: CanvasBackend{}, + fonts: make(map[string]*font.SFNT), + images: make(map[string]image.Point), + resourceID: make(map[editorResourceKey]string), + fontMetrics: make(map[string]FontMetrics), + backends: defaultRenderBackends(), } } @@ -1064,102 +1079,19 @@ func scaleTextNumbers(value string, scale float64) string { return strings.Join(fields, " ") } -// LayoutText 按嵌入字体度量和段落选项重排横向文字,保留绘制属性,不进行复杂文字塑形 -// 不修改文档,通过AddObject或UpdateObject提交;选项仅供当前编辑过程使用,保存为标准文字定位 +// LayoutText 使用编辑器字体后端重排文字,不修改文档和历史 // 入参: obj 文字对象, value 原文, options 段落排版选项 -// 返回: error 错误信息 +// 返回: error 字体或排版错误 func (e *Editor) LayoutText(obj *TextObject, value string, options TextLayout) error { - if obj.ReadDirection != 0 || obj.CharDirection != 0 { - return fmt.Errorf("automatic text layout requires horizontal text") - } - sfnt, err := e.editorFont(obj.Font) + metrics, err := e.editorFont(obj.Font) if err != nil { return err } - if !finite(obj.Size) || obj.Size <= 0 || !finite(obj.HScale) || obj.HScale < 0 { - return fmt.Errorf("invalid text dimensions") - } - value = strings.ReplaceAll(strings.ReplaceAll(value, "\r\n", "\n"), "\r", "\n") - if !utf8.ValidString(value) || strings.Contains(value, "\t") || strings.Trim(value, "\n") == "" { - return fmt.Errorf("text must contain UTF-8 characters without tabs") - } - lineHeight := options.LineHeight - if !finite(lineHeight) || lineHeight < 0 { - return fmt.Errorf("line height must be finite and nonnegative") - } - if !finite(options.LetterSpacing) { - return fmt.Errorf("letter spacing must be finite") - } - if !finite(options.LeftIndent) || !finite(options.RightIndent) || !finite(options.FirstLineIndent) { - return fmt.Errorf("paragraph indents must be finite") - } - if !slices.Contains([]string{"", "left", "center", "right", "justify"}, options.Align) { - return fmt.Errorf("invalid text alignment %q", options.Align) - } - var width float64 - if options.Wrap || options.Align != "" && options.Align != "left" || options.LeftIndent != 0 || options.RightIndent != 0 || options.FirstLineIndent != 0 { - box, err := obj.TextFrame() - if err != nil { - return err - } - width = box.W - options.LeftIndent - options.RightIndent - if !finite(width) || width <= 0 || !finite(width-options.FirstLineIndent) || width-options.FirstLineIndent <= 0 { - return fmt.Errorf("paragraph indents leave no text width") - } - } - hScale := obj.HScale - if hScale == 0 { - hScale = 1 - } - ascender, descender, gap := sfnt.VerticalMetrics() - unit := obj.Size / float64(sfnt.UnitsPerEm()) - if lineHeight == 0 { - lineHeight = math.Max(obj.Size, float64(int(ascender)-int(descender)+int(gap))*unit) - } - var codes []TextCode - var missing []rune - for _, paragraph := range strings.Split(value, "\n") { - runes := []rune(paragraph) - advances := make([]float64, len(runes)) - for i, char := range runes { - glyph := sfnt.GlyphIndex(char) - if glyph == 0 { - missing = append(missing, char) - continue - } - advances[i] = float64(sfnt.GlyphAdvance(glyph)) * unit * hScale - } - if len(missing) != 0 { - continue - } - spaceTextAdvances(runes, advances, options.LetterSpacing) - for _, advance := range advances { - if !finite(advance) { - return fmt.Errorf("text advance exceeds finite range") - } - } - lines := breakTextLines(runes, advances, width, options.Wrap, options.LetterSpacing, options.FirstLineIndent) - for i, line := range lines { - indent := 0.0 - if i == 0 { - indent = options.FirstLineIndent - } - x, deltas := alignTextLine(runes[line[0]:line[1]], advances[line[0]:line[1]], width-indent, options.Align, i+1 < len(lines), options.LetterSpacing) - x += options.LeftIndent + indent - y := float64(ascender)*unit + float64(len(codes))*lineHeight - if !finite(x) || !finite(y) { - return fmt.Errorf("text position exceeds finite range") - } - codes = append(codes, TextCode{X: ofdNumber(x), Y: ofdNumber(y), DeltaX: deltas, Value: escapeOFDText(string(runes[line[0]:line[1]]))}) - } - } - if err := missingGlyphError(obj.Font, missing); err != nil { + if err := LayoutText(obj, value, options, metrics); err != nil { return err } - obj.TextCode = codes - obj.layout = nil - if options != (TextLayout{}) || e.objectOrigin(obj.ID) != nil { - obj.layout = &textLayout{value: value, options: options} + if obj.layout == nil && e.objectOrigin(obj.ID) != nil { + obj.layout = &textLayout{value: obj.Text(), options: options} } return nil } diff --git a/ofdgo_editor_composite.go b/ofdgo_editor_composite.go index 8e317ec..3527725 100644 --- a/ofdgo_editor_composite.go +++ b/ofdgo_editor_composite.go @@ -23,8 +23,6 @@ import ( "math" "reflect" "slices" - - "github.com/tdewolff/canvas" ) // ObjectPath 定位页面顶层对象或注解外观内的成员,Children为从0开始的逐层绘制序号 @@ -60,7 +58,7 @@ type editorCompositeNode struct { boundaryInCTM bool defaults *DrawParam drawParams []string - clip *canvas.Path + clip *GeometryPath visible bool alpha *int ref *editorCompositeNode @@ -404,7 +402,17 @@ func (e *Editor) compositeMembers(n *editorCompositeNode, reader *Reader, render copy.TransFlag = nil clips, clipMatrix = ©, boundary } - clip := intersectClipPath(n.clip, renderer.buildClipPath(clips, 0, 0, 0, clipMatrix)) + clip := n.clip + if clips != nil { + geometry, err := renderer.Geometry() + if err != nil { + return nil, err + } + clip, err = geometry.Clip(renderer, clips, clipMatrix, n.clip) + if err != nil { + return nil, err + } + } defaults := renderer.drawParamDefaults(c.DrawParam, n.defaults) drawParams := append(slices.Clone(n.drawParams), c.DrawParam) visible := n.visible && (c.Visible == nil || *c.Visible) @@ -529,7 +537,7 @@ func (e *Editor) measureCompositeMembers(renderer *Renderer, nodes []*editorComp } if node.visible { var err error - bounds, err = renderer.MeasureObject(object, MeasureOptions{Defaults: node.defaults, Parent: &node.parent, BoundaryInCTM: node.boundaryInCTM, Clip: geometryCanvasPath(node.clip), Contours: true}) + bounds, err = renderer.MeasureObject(object, MeasureOptions{Defaults: node.defaults, Parent: &node.parent, BoundaryInCTM: node.boundaryInCTM, Clip: node.clip, Contours: true}) if err != nil { return nil, err } diff --git a/ofdgo_editor_composite_copy.go b/ofdgo_editor_composite_copy.go index 09cf2aa..89ad615 100644 --- a/ofdgo_editor_composite_copy.go +++ b/ofdgo_editor_composite_copy.go @@ -19,9 +19,6 @@ import ( "fmt" "maps" "slices" - "strings" - - "github.com/tdewolff/canvas" ) // CompositeSelection 同一内部容器的独立选区快照,共享不可变字体和图片资源 @@ -107,7 +104,7 @@ func (e *Editor) pasteCompositeSelection(selection *CompositeSelection, paste fu object = style } object = mergeGraphicObjectAlpha(object, source.alpha) - if !source.visible || source.clip != nil && source.clip.Empty() { + if !source.visible || source.clip != nil && len(*source.clip) == 0 { visible := false switch object.Type { case "TextObject": @@ -135,8 +132,11 @@ func (e *Editor) pasteCompositeSelection(selection *CompositeSelection, paste fu if err := e.isolateCompositeStyle(copy); err != nil { return err } - if source.clip != nil && !source.clip.Empty() { - shape := compositeClipPath(source.clip) + if source.clip != nil && len(*source.clip) != 0 { + shape, err := geometryClipPath(*source.clip) + if err != nil { + return err + } clips := *editorObjectClips(©.object) matrix, ok := copy.matrix(clips == nil || clips.TransFlag == nil || *clips.TransFlag).Invert() if !ok { @@ -224,44 +224,6 @@ func (n *editorCompositeNode) convertCoordinates(parent Matrix, inward bool) err return nil } -// compositeClipPath 将已解析的父裁剪转换为标准紧缩路径,使用页面毫米坐标 -// 入参: path 画布裁剪 -// 返回: PathObject 标准裁剪路径 -func compositeClipPath(path *canvas.Path) PathObject { - return editorClipPath(path.Copy().Transform(canvas.Matrix{{1, 0, 0}, {0, -1, 0}})) -} - -// editorClipPath 将页面坐标路径写为标准紧缩裁剪路径 -// 入参: path 页面毫米路径 -// 返回: PathObject 标准裁剪路径 -func editorClipPath(path *canvas.Path) PathObject { - path = path.ReplaceArcs() - data := path.Data() - var value strings.Builder - for i := 0; i < len(data); { - switch data[i] { - case canvas.MoveToCmd, canvas.LineToCmd: - command := "L" - if data[i] == canvas.MoveToCmd { - command = "M" - } - fmt.Fprintf(&value, "%s %s %s ", command, ofdNumber(data[i+1]), ofdNumber(data[i+2])) - i += 4 - case canvas.QuadToCmd: - fmt.Fprintf(&value, "Q %s %s %s %s ", ofdNumber(data[i+1]), ofdNumber(data[i+2]), ofdNumber(data[i+3]), ofdNumber(data[i+4])) - i += 6 - case canvas.CubeToCmd: - fmt.Fprintf(&value, "B %s %s %s %s %s %s ", ofdNumber(data[i+1]), ofdNumber(data[i+2]), ofdNumber(data[i+3]), ofdNumber(data[i+4]), ofdNumber(data[i+5]), ofdNumber(data[i+6])) - i += 8 - case canvas.CloseCmd: - value.WriteString("C ") - i += 4 - } - } - fill, stroke := true, false - return PathObject{Boundary: "0 0 1 1", Fill: &fill, Stroke: &stroke, AbbreviatedData: strings.TrimSpace(value.String())} -} - // CaptureCompositeObjects 捕获内部选区,不修改文档或分配资源 // 入参: page 页面索引, path 父复合路径, indexes 同一容器的成员序号 // 返回: *CompositeSelection 独立快照, error 错误信息 diff --git a/ofdgo_editor_erase.go b/ofdgo_editor_erase.go index 8f7be4c..7e741d6 100644 --- a/ofdgo_editor_erase.go +++ b/ofdgo_editor_erase.go @@ -20,8 +20,6 @@ import ( "reflect" "slices" "strings" - - "github.com/tdewolff/canvas" ) // EraseObjects 擦除同页对象与矩形区域重叠的可见部分,一次撤销恢复全部 @@ -41,34 +39,41 @@ func (e *Editor) EraseObjects(page int, ids []string, box Box) error { // 入参: page 页面索引, ids 对象标识, points 页面毫米坐标,至少三个点 // 返回: error 错误信息 func (e *Editor) EraseObjectsPath(page int, ids []string, points []Point) error { - region, err := editorEraseRegion(points) - if err != nil || region.Empty() { + region, err := e.editorEraseRegion(points) + if err != nil || len(region) == 0 { return err } - bounds := region.FastBounds() - return e.eraseObjects(page, ids, Box{X: bounds.X0, Y: bounds.Y0, W: bounds.W(), H: bounds.H()}, points, region) + bounds, err := e.backends.Geometry.Bounds(region) + if err != nil { + return err + } + return e.eraseObjects(page, ids, bounds, points, region) } // editorEraseRegion 校验闭合折线并按奇偶规则构建擦除区域 // 入参: points 页面毫米坐标 -// 返回: *canvas.Path 擦除区域, error 错误信息 -func editorEraseRegion(points []Point) (*canvas.Path, error) { +// 返回: GeometryPath 擦除区域, error 错误信息 +func (e *Editor) editorEraseRegion(points []Point) (GeometryPath, error) { + geometry, err := e.Geometry() + if err != nil { + return nil, err + } if len(points) < 3 { return nil, fmt.Errorf("erase path requires at least three points") } - region := &canvas.Path{} + region := make(GeometryPath, 0, len(points)+1) for i, point := range points { if !finite(point.X) || !finite(point.Y) { return nil, fmt.Errorf("invalid erase path point %d", i) } + verb := GeometryLine if i == 0 { - region.MoveTo(point.X, point.Y) - } else { - region.LineTo(point.X, point.Y) + verb = GeometryMove } + region = append(region, GeometrySegment{Verb: verb, End: point}) } - region.Close() - return region.Settle(canvas.EvenOdd), nil + region = append(region, GeometrySegment{Verb: GeometryClose, End: points[0]}) + return geometry.Normalize(region, true) } // EraseCompositeObjects 擦除内部成员与矩形相交的部分,保留原始属性及共享资源 @@ -79,7 +84,7 @@ func (e *Editor) EraseCompositeObjects(page int, path ObjectPath, indexes []int, if _, err := creationBox(editorBoxString(box)); err != nil { return err } - return e.eraseCompositeObjects(page, path, indexes, canvas.Rectangle(box.W, box.H).Translate(box.X, box.Y)) + return e.eraseCompositeObjects(page, path, indexes, geometryRectangle(box)) } // EraseCompositeObjectsPath 按闭合折线擦除内部成员,末点自动连接起点 @@ -87,8 +92,8 @@ func (e *Editor) EraseCompositeObjects(page int, path ObjectPath, indexes []int, // 入参: page 页面索引, path 父路径, indexes 成员序号, points 页面毫米坐标 // 返回: error 错误信息 func (e *Editor) EraseCompositeObjectsPath(page int, path ObjectPath, indexes []int, points []Point) error { - region, err := editorEraseRegion(points) - if err != nil || region.Empty() { + region, err := e.editorEraseRegion(points) + if err != nil || len(region) == 0 { return err } return e.eraseCompositeObjects(page, path, indexes, region) @@ -97,15 +102,30 @@ func (e *Editor) EraseCompositeObjectsPath(page int, path ObjectPath, indexes [] // eraseCompositeObjects 按页面区域裁剪内部成员,全部成功后提交一次历史 // 入参: page 页面索引, path 父路径, indexes 成员序号, region 擦除区域 // 返回: error 错误信息 -func (e *Editor) eraseCompositeObjects(page int, path ObjectPath, indexes []int, region *canvas.Path) error { +func (e *Editor) eraseCompositeObjects(page int, path ObjectPath, indexes []int, region GeometryPath) error { + geometry, err := e.Geometry() + if err != nil { + return err + } return e.editCompositeObjects(page, path, indexes, func(_ *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error { for i, node := range nodes { box := members[i].Bounds - bounds := canvas.Rectangle(box.W, box.H).Translate(box.X, box.Y) - if box.W <= 0 || box.H <= 0 || bounds.And(region).Empty() { + if box.W <= 0 || box.H <= 0 { continue } - if bounds.Not(region).Empty() { + bounds := geometryRectangle(box) + intersection, err := geometry.Combine(bounds, region, GeometryIntersect) + if err != nil { + return err + } + if len(intersection) == 0 { + continue + } + remainder, err := geometry.Combine(bounds, region, GeometrySubtract) + if err != nil { + return err + } + if len(remainder) == 0 { node.owner.patches = append(node.owner.patches, editorXMLPatch{node.span.start, node.span.end, nil}) continue } @@ -117,8 +137,16 @@ func (e *Editor) eraseCompositeObjects(page int, path ObjectPath, indexes []int, if !ok { return fmt.Errorf("composite clip transform is not invertible") } - outer := canvas.Rectangle(box.W+2, box.H+2).Translate(box.X-1, box.Y-1) - if err := appendCompositeClip(node, editorClipPath(outer.Not(region)), inverse, false); err != nil { + outer := geometryRectangle(Box{X: box.X - 1, Y: box.Y - 1, W: box.W + 2, H: box.H + 2}) + remainder, err = geometry.Combine(outer, region, GeometrySubtract) + if err != nil { + return err + } + shape, err := geometryClipPath(remainder) + if err != nil { + return err + } + if err := appendCompositeClip(node, shape, inverse, false); err != nil { return err } } @@ -129,7 +157,7 @@ func (e *Editor) eraseCompositeObjects(page int, path ObjectPath, indexes []int, // eraseObjects 按矩形或闭合路径裁剪对象,只在全部对象处理成功后提交修改 // 入参: page 页面索引, ids 对象标识, box 区域边界, points 折线点, region 奇偶填充后的路径 // 返回: error 错误信息 -func (e *Editor) eraseObjects(page int, ids []string, box Box, points []Point, region *canvas.Path) error { +func (e *Editor) eraseObjects(page int, ids []string, box Box, points []Point, region GeometryPath) error { objects, indexes, err := e.selectedObjects(page, ids) if err != nil || len(objects) == 0 { return err @@ -150,11 +178,19 @@ func (e *Editor) eraseObjects(page int, ids []string, box Box, points []Point, r } covered := x == bounds.X && y == bounds.Y && right == bounds.X+bounds.W && bottom == bounds.Y+bounds.H if region != nil { - path := canvas.Rectangle(bounds.W, bounds.H).Translate(bounds.X, bounds.Y) - if path.And(region).Empty() { + path := geometryRectangle(bounds) + intersection, err := e.backends.Geometry.Combine(path, region, GeometryIntersect) + if err != nil { + return err + } + if len(intersection) == 0 { continue } - covered = path.Not(region).Empty() + remainder, err := e.backends.Geometry.Combine(path, region, GeometrySubtract) + if err != nil { + return err + } + covered = len(remainder) == 0 } if covered { removed[ids[i]] = true diff --git a/ofdgo_editor_history.go b/ofdgo_editor_history.go index f1b0275..2decd23 100644 --- a/ofdgo_editor_history.go +++ b/ofdgo_editor_history.go @@ -60,6 +60,7 @@ func (e *Editor) transactionSnapshot() Editor { next.resources = slices.Clone(e.resources) next.fonts, next.images, next.resourceID = maps.Clone(e.fonts), maps.Clone(e.images), maps.Clone(e.resourceID) next.origins = maps.Clone(e.origins) + next.fontMetrics = maps.Clone(e.fontMetrics) if e.source != nil { source := *e.source source.pages, source.origins = maps.Clone(source.pages), maps.Clone(source.origins) @@ -80,6 +81,8 @@ func (e *Editor) restoreTransaction(state Editor) { maps.Copy(state.resourceID, e.resourceID) state.history, state.historyIndex, state.historyLimit = e.history, e.historyIndex, e.historyLimit state.serial, state.maxID = max(e.serial, state.serial), max(e.maxID, state.maxID) + state.backends, state.fontFS = e.backends, e.fontFS + state.fontRenderer, state.fontMetrics = e.fontRenderer, e.fontMetrics *e = state } diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go index 00add27..117c2fd 100644 --- a/ofdgo_editor_open.go +++ b/ofdgo_editor_open.go @@ -25,9 +25,6 @@ import ( "reflect" "strconv" "strings" - - "github.com/tdewolff/canvas" - "github.com/tdewolff/font" ) // editorSource 保留输入包及按需加载的原始页面 @@ -629,40 +626,40 @@ func editorGeometry(object GraphicObject) (string, string) { // editorFont 按需解析内嵌字体或名称匹配的外部字体,不使用无关回退字体 // 入参: id 字体资源标识 -// 返回: *font.SFNT 字体, error 错误信息 -func (e *Editor) editorFont(id string) (*font.SFNT, error) { - if sfnt := e.fonts[id]; sfnt != nil { - return sfnt, nil +// 返回: FontMetrics 字体度量, error 错误信息 +func (e *Editor) editorFont(id string) (FontMetrics, error) { + if metrics := e.fontMetrics[id]; metrics != nil { + return metrics, nil } - if e.source != nil { - definition := e.source.reader.fontCache[id] - if definition != nil && definition.FontFile == "" { - if e.fontRenderer == nil { - e.fontRenderer = e.newRenderer(e.source.reader) - } - style := canvasFontStyle(definition) - for _, source := range e.fontRenderer.fontSources(id, definition, style) { - if !source.exact { - continue - } - if family := e.fontRenderer.loadFontSource(canvas.NewFontFamily(definition.FontName), source, style); family != nil { - return family.Face(12, style).Font.SFNT, nil - } - } - return nil, &EditError{Code: EditFontUnavailable, Err: fmt.Errorf("font %q is unavailable", definition.FontName)} + if e.backends.Fonts == nil { + return nil, &EditError{Code: EditFontUnavailable, Err: fmt.Errorf("fonts: %w", ErrBackendUnavailable)} + } + var data []byte + for _, resource := range e.resources { + if resource.font != nil && resource.font.ID == id { + data = resource.data + break } - data, err := e.source.reader.FontData(id) + } + if data == nil && e.source != nil { + if e.fontRenderer == nil { + e.fontRenderer = e.newRenderer(e.source.reader) + } + resolved, err := e.fontRenderer.ResolveFont(id, true) if err != nil { return nil, &EditError{Code: EditFontUnavailable, Err: err} } - sfnt, err := font.ParseSFNT(data, 0) - if err != nil { - return nil, &EditError{Code: EditFontUnavailable, Err: err} - } - e.fonts[id] = sfnt - return sfnt, nil + data = resolved.Data } - return nil, &EditError{Code: EditFontUnavailable, Err: fmt.Errorf("font %q not found", id)} + if data == nil { + return nil, &EditError{Code: EditFontUnavailable, Err: fmt.Errorf("font %q not found", id)} + } + metrics, err := e.backends.Fonts.OpenFont(data) + if err != nil { + return nil, &EditError{Code: EditFontUnavailable, Err: err} + } + e.fontMetrics[id] = metrics + return metrics, nil } // FontData 获取编辑使用的字体数据,外部字体不写入文档资源 diff --git a/ofdgo_font.go b/ofdgo_font.go index 2b24cc5..4974978 100644 --- a/ofdgo_font.go +++ b/ofdgo_font.go @@ -16,8 +16,18 @@ package ofdgo import ( "encoding/binary" + + "github.com/tdewolff/font" ) +// fontSFNTData 保留字体原始表数据和时间,重新计算目录与校验和 +// 入参: sfnt 已解析的字体 +// 返回: []byte 独立SFNT数据 +func fontSFNTData(sfnt *font.SFNT) []byte { + data, _ := serializeOTF(sfnt.Tables) + return data +} + // FixFontDataAggressive 激进修复字体数据 // 尝试修复缺失表(OS/2, cmap等)的TrueType字体或包装CFF裸数据 // 对显式字形映射补充私有字符cmap,便于按glyph id渲染 diff --git a/ofdgo_font_backend.go b/ofdgo_font_backend.go new file mode 100644 index 0000000..12a8d4a --- /dev/null +++ b/ofdgo_font_backend.go @@ -0,0 +1,75 @@ +// 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" + +// FontMetrics 提供不依赖绘图库的字体度量,所有整数尺寸使用字体设计单位 +// VerticalMetrics依次返回非负的上升高度、下降高度和行间隙 +// Write返回独立的SFNT数据,调用方不得修改字体实例 +type FontMetrics interface { + UnitsPerEm() uint16 + NumGlyphs() uint16 + GlyphIndex(rune) uint16 + GlyphAdvance(uint16) uint16 + VerticalMetrics() (uint16, uint16, uint16) + Write() []byte +} + +// ResolvedFont 保存字体来源和只读字体数据,不将外部字体写入文档 +type ResolvedFont struct { + Data []byte + Source string + Face *FontFace + Exact bool +} + +// FontBackend 统一字体来源与度量,exact禁止使用无关回退字体 +// ResolveFont不得递归调用Renderer.ResolveFont,OpenFont不得修改输入数据 +type FontBackend interface { + Backend + ResolveFont(renderer *Renderer, id string, exact bool) (ResolvedFont, error) + OpenFont(data []byte) (FontMetrics, error) +} + +// resolvedFontKey 区分只读资源的字体标识与精确匹配要求 +type resolvedFontKey struct { + id string + exact bool + definition *Font +} + +// resolvedFontResult 缓存字体解析结果及不可用原因 +type resolvedFontResult struct { + font ResolvedFont + err error +} + +// ResolveFont 使用配置的字体后端解析资源,保留精确匹配与回退的区别 +// 入参: id 字体资源标识, exact 是否要求精确匹配 +// 返回: ResolvedFont 字体与来源, error 解析错误 +func (r *Renderer) ResolveFont(id string, exact bool) (ResolvedFont, error) { + if r.backends.Fonts == nil { + return ResolvedFont{}, fmt.Errorf("fonts: %w", ErrBackendUnavailable) + } + key := resolvedFontKey{id: id, exact: exact, definition: r.Reader.fontCache[id]} + if cached, ok := r.resolvedFonts[key]; ok { + return cached.font, cached.err + } + resolved, err := r.backends.Fonts.ResolveFont(r, id, exact) + key.definition = r.Reader.fontCache[id] + r.resolvedFonts[key] = resolvedFontResult{font: resolved, err: err} + return resolved, err +} diff --git a/ofdgo_font_info.go b/ofdgo_font_info.go index 7892bac..a1278b2 100644 --- a/ofdgo_font_info.go +++ b/ofdgo_font_info.go @@ -298,11 +298,10 @@ func (r *Renderer) fontInfo(font Font) FontInfo { } return info } - if source, family := r.fontSourceMatch(font.ID, &font); family != nil { - info.Matched = source.name - face := fontFaceInfo(family.Face(12, canvasFontStyle(&font)).Font.SFNT.Tables["name"], source.face) - info.MatchedFace = &face - if source.exact { + if resolved, err := r.ResolveFont(font.ID, false); err == nil && resolved.Source != "" { + info.Matched = resolved.Source + info.MatchedFace = resolved.Face + if resolved.Exact { info.Status = FontStatusMatched info.Detail = "使用外部字体文件" } else { diff --git a/ofdgo_font_util.go b/ofdgo_font_util.go index bd99c59..5694924 100644 --- a/ofdgo_font_util.go +++ b/ofdgo_font_util.go @@ -704,6 +704,9 @@ func serializeOTF(tables map[string][]byte) ([]byte, error) { pad := (4 - (len(data) % 4)) % 4 padded := make([]byte, len(data)+pad) copy(padded, data) + if tag == "head" && len(data) >= 12 { + clear(padded[8:12]) + } cs := calcTableChecksum(padded) records = append(records, otfTableRecord{tag, cs, offset, len(data), padded}) offset += len(padded) diff --git a/ofdgo_geom_backend.go b/ofdgo_geom_backend.go new file mode 100644 index 0000000..0b3246e --- /dev/null +++ b/ofdgo_geom_backend.go @@ -0,0 +1,96 @@ +// 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" + +// GeometryOperation 指定路径填充区域的布尔运算 +type GeometryOperation uint8 + +const ( + GeometryIntersect GeometryOperation = iota + GeometryUnion + GeometrySubtract + GeometryXor +) + +// StrokeOptions 描述毫米单位的描边,Cap和Join使用OFD名称 +// Tolerance为曲线展开误差,0采用后端默认精度,Dashes和DashOffset为绝对长度 +type StrokeOptions struct { + Width float64 + Cap, Join string + MiterLimit, DashOffset, Tolerance float64 + Dashes []float64 +} + +// GeometryBackend 操作库自有路径,坐标以页面左上角为原点,不修改任何输入 +// Clip返回nil表示不裁剪,空路径指针表示完全裁去,矩阵映射到页面坐标 +type GeometryBackend interface { + Backend + Path(object PathObject) (GeometryPath, error) + Region(region *Region, matrix Matrix) (GeometryPath, error) + Bounds(path GeometryPath) (Box, error) + Transform(path GeometryPath, matrix Matrix) (GeometryPath, error) + Normalize(path GeometryPath, evenOdd bool) (GeometryPath, error) + Combine(left, right GeometryPath, operation GeometryOperation) (GeometryPath, error) + Stroke(path GeometryPath, options StrokeOptions) (GeometryPath, error) + Clip(renderer *Renderer, clips *Clips, matrix Matrix, parent *GeometryPath) (*GeometryPath, error) +} + +// Geometry 返回当前几何后端,不隐式恢复默认实现 +// 返回: GeometryBackend 几何后端, error 未配置错误 +func (r *Renderer) Geometry() (GeometryBackend, error) { + if r.backends.Geometry == nil { + return nil, fmt.Errorf("geometry: %w", ErrBackendUnavailable) + } + return r.backends.Geometry, nil +} + +// Geometry 返回编辑器配置的几何后端 +// 返回: GeometryBackend 几何后端, error 未配置错误 +func (e *Editor) Geometry() (GeometryBackend, error) { + if e.backends.Geometry == nil { + return nil, fmt.Errorf("geometry: %w", ErrBackendUnavailable) + } + return e.backends.Geometry, nil +} + +// PathOutline 使用配置的几何后端生成页面坐标中的SVG轮廓 +// 入参: object 路径对象 +// 返回: string SVG路径, error 几何或编码错误 +func (r *Renderer) PathOutline(object PathObject) (string, error) { + geometry, err := r.Geometry() + if err != nil { + return "", err + } + path, err := geometry.Path(object) + if err != nil { + return "", err + } + return path.SVG() +} + +// geometryRectangle 构建页面坐标矩形,保留输入精度 +// 入参: box 矩形范围 +// 返回: GeometryPath 闭合路径 +func geometryRectangle(box Box) GeometryPath { + return GeometryPath{ + {Verb: GeometryMove, End: Point{X: box.X, Y: box.Y}}, + {Verb: GeometryLine, End: Point{X: box.X + box.W, Y: box.Y}}, + {Verb: GeometryLine, End: Point{X: box.X + box.W, Y: box.Y + box.H}}, + {Verb: GeometryLine, End: Point{X: box.X, Y: box.Y + box.H}}, + {Verb: GeometryClose, End: Point{X: box.X, Y: box.Y}}, + } +} diff --git a/ofdgo_geom_path.go b/ofdgo_geom_path.go index 9bb36e5..4672e6a 100644 --- a/ofdgo_geom_path.go +++ b/ofdgo_geom_path.go @@ -14,6 +14,11 @@ package ofdgo +import ( + "fmt" + "strings" +) + // GeometryPath 保存独立于绘图库的几何路径,不扁平化曲线或舍入坐标 // 坐标以左上角为原点,单位为毫米,子路径从Move开始,Close回到当前子路径起点 type GeometryPath []GeometrySegment @@ -40,3 +45,69 @@ type GeometrySegment struct { Rotation float64 Large, Sweep bool } + +// SVG 将几何路径编码为SVG路径,不改变曲线或坐标精度 +// 返回: string SVG路径, error 不支持的路径指令 +func (p GeometryPath) SVG() (string, error) { + return p.encode(true) +} + +// OFD 将几何路径编码为OFD紧缩路径 +// 返回: string 紧缩路径, error 不支持的路径指令 +func (p GeometryPath) OFD() (string, error) { + return p.encode(false) +} + +// encode 按目标格式写入路径,圆弧标志采用两种格式均支持的0和1 +// 入参: svg 是否使用SVG指令 +// 返回: string 路径数据, error 不支持的路径指令 +func (p GeometryPath) encode(svg bool) (string, error) { + var value strings.Builder + for _, segment := range p { + x, y := ofdNumber(segment.End.X), ofdNumber(segment.End.Y) + switch segment.Verb { + case GeometryMove: + fmt.Fprintf(&value, "M %s %s ", x, y) + case GeometryLine: + fmt.Fprintf(&value, "L %s %s ", x, y) + case GeometryQuad: + fmt.Fprintf(&value, "Q %s %s %s %s ", ofdNumber(segment.Control1.X), ofdNumber(segment.Control1.Y), x, y) + case GeometryCubic: + command := "B" + if svg { + command = "C" + } + fmt.Fprintf(&value, "%s %s %s %s %s %s %s ", command, ofdNumber(segment.Control1.X), ofdNumber(segment.Control1.Y), ofdNumber(segment.Control2.X), ofdNumber(segment.Control2.Y), x, y) + case GeometryArc: + large, sweep := 0, 0 + if segment.Large { + large = 1 + } + if segment.Sweep { + sweep = 1 + } + fmt.Fprintf(&value, "A %s %s %s %d %d %s %s ", ofdNumber(segment.RadiusX), ofdNumber(segment.RadiusY), ofdNumber(segment.Rotation), large, sweep, x, y) + case GeometryClose: + if svg { + value.WriteString("Z ") + } else { + value.WriteString("C ") + } + default: + return "", fmt.Errorf("unsupported geometry command %d", segment.Verb) + } + } + return strings.TrimSpace(value.String()), nil +} + +// geometryClipPath 将页面路径转换为标准填充裁剪对象 +// 入参: path 页面毫米路径 +// 返回: PathObject 裁剪路径, error 无效指令错误 +func geometryClipPath(path GeometryPath) (PathObject, error) { + data, err := path.OFD() + if err != nil { + return PathObject{}, err + } + fill, stroke := true, false + return PathObject{Boundary: "0 0 1 1", Fill: &fill, Stroke: &stroke, AbbreviatedData: data}, nil +} diff --git a/ofdgo_image.go b/ofdgo_image.go index 4f32fa1..8fcef57 100644 --- a/ofdgo_image.go +++ b/ofdgo_image.go @@ -21,10 +21,12 @@ import ( "image" "image/color" _ "image/gif" + "math" canvasimage "github.com/tdewolff/canvas/image" _ "github.com/xiaoqidun/jbig2" _ "golang.org/x/image/bmp" + "golang.org/x/image/draw" _ "golang.org/x/image/tiff" ) @@ -175,3 +177,308 @@ func parseBMPHeader(data []byte) (int, int, int, uint16, uint32, error) { } return offset, width, height, bpp, compression, nil } + +// imageWithMask 应用图片蒙版 +// 入参: img 图片对象, mask 蒙版图片 +// 返回: image.Image 蒙版处理后的图片对象 +func imageWithMask(img, mask image.Image) image.Image { + if img == nil || mask == nil { + return img + } + bounds := img.Bounds() + if bounds.Empty() || mask.Bounds().Empty() { + return img + } + source := imagePixelSource(img) + maskSource := imagePixelSource(mask) + maskBounds := maskSource.Bounds() + var opacity image.Image = maskSource + if bounds.Size() != maskBounds.Size() { + resized := image.NewGray(bounds) + draw.CatmullRom.Scale(resized, bounds, maskSource, maskBounds, draw.Src, nil) + opacity = resized + maskBounds = bounds + } + out := image.NewNRGBA(bounds) + if src, ok := source.(*image.NRGBA); ok { + for y := 0; y < bounds.Dy(); y++ { + offset := src.PixOffset(bounds.Min.X, bounds.Min.Y+y) + row := out.Pix[y*out.Stride : (y+1)*out.Stride] + copy(row, src.Pix[offset:offset+len(row)]) + for x := 3; x < len(row); x += 4 { + a := imageGrayAt(opacity, maskBounds.Min.X+x/4, maskBounds.Min.Y+y).Y + row[x] = uint8(int(row[x]) * int(a) / 255) + } + } + return out + } + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + mx := maskBounds.Min.X + x - bounds.Min.X + my := maskBounds.Min.Y + y - bounds.Min.Y + a := imageGrayAt(opacity, mx, my).Y + c := imageNRGBAAt(source, x, y) + c.A = uint8(int(c.A) * int(a) / 255) + out.SetNRGBA(x, y, c) + } + } + return out +} + +// imageWithAlpha 合并图片透明度 +// 入参: img 图片对象, alpha 对象透明度 +// 返回: image.Image 合并后的图片对象 +func imageWithAlpha(img image.Image, alpha *int) image.Image { + if img == nil || alpha == nil { + return img + } + a := clampColor(*alpha) + if a == 255 { + return img + } + bounds := img.Bounds() + out := image.NewNRGBA(bounds) + source := imagePixelSource(img) + if src, ok := source.(*image.NRGBA); ok { + for y := 0; y < bounds.Dy(); y++ { + offset := src.PixOffset(bounds.Min.X, bounds.Min.Y+y) + row := out.Pix[y*out.Stride : (y+1)*out.Stride] + copy(row, src.Pix[offset:offset+len(row)]) + for x := 3; x < len(row); x += 4 { + row[x] = uint8(int(row[x]) * a / 255) + } + } + return out + } + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + c := imageNRGBAAt(source, x, y) + c.A = uint8(int(c.A) * a / 255) + out.SetNRGBA(x, y, c) + } + } + return out +} + +// imagePixelSource 获取图片像素源 +// 入参: img 图片对象 +// 返回: image.Image 图片像素源 +func imagePixelSource(img image.Image) image.Image { + if src, ok := img.(interface{ Image() (image.Image, error) }); ok { + if decoded, err := src.Image(); err == nil { + return decoded + } + } + return img +} + +// imageGrayAt 获取图片灰度像素 +// 入参: img 图片对象, x X坐标, y Y坐标 +// 返回: color.Gray 灰度像素 +func imageGrayAt(img image.Image, x, y int) color.Gray { + if src, ok := img.(*image.Gray); ok { + return src.GrayAt(x, y) + } + if src, ok := img.(*image.Paletted); ok { + return color.GrayModel.Convert(src.At(x, y)).(color.Gray) + } + if src, ok := img.(image.RGBA64Image); ok { + r, g, b, _ := src.RGBA64At(x, y).RGBA() + return color.Gray{Y: uint8((19595*r + 38470*g + 7471*b + 1<<15) >> 24)} + } + return color.GrayModel.Convert(img.At(x, y)).(color.Gray) +} + +// imageNRGBAAt 获取图片NRGBA像素 +// 入参: img 图片对象, x X坐标, y Y坐标 +// 返回: color.NRGBA NRGBA像素 +func imageNRGBAAt(img image.Image, x, y int) color.NRGBA { + if src, ok := img.(*image.NRGBA); ok { + return src.NRGBAAt(x, y) + } + if src, ok := img.(*image.YCbCr); ok { + r, g, b, _ := src.YCbCrAt(x, y).RGBA() + return color.NRGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: 255} + } + if src, ok := img.(*image.RGBA); ok { + c := src.RGBA64At(x, y) + if c.A == 0 { + return color.NRGBA{} + } + r, g, b, a := uint32(c.R), uint32(c.G), uint32(c.B), uint32(c.A) + if a != 0xffff { + r = r * 0xffff / a + g = g * 0xffff / a + b = b * 0xffff / a + } + return color.NRGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: uint8(a >> 8)} + } + return color.NRGBAModel.Convert(img.At(x, y)).(color.NRGBA) +} + +// transparentEdgeColor 获取透明像素相邻的可见颜色 +// 入参: img 图片对象, x X坐标, y Y坐标 +// 返回: color.NRGBA 颜色, bool 是否存在 +func transparentEdgeColor(img *image.NRGBA, x, y int) (color.NRGBA, bool) { + bounds := img.Bounds() + var best color.NRGBA + for dy := -1; dy <= 1; dy++ { + for dx := -1; dx <= 1; dx++ { + if dx == 0 && dy == 0 { + continue + } + nx, ny := x+dx, y+dy + if nx < bounds.Min.X || nx >= bounds.Max.X || ny < bounds.Min.Y || ny >= bounds.Max.Y { + continue + } + c := img.NRGBAAt(nx, ny) + if c.A > best.A { + best = c + } + } + } + if best.A == 0 { + return color.NRGBA{}, false + } + best.A = 1 + return best, true +} + +// transparentPaddingColor 获取透明补齐颜色 +// 入参: c 边缘颜色 +// 返回: color.NRGBA 补齐颜色 +func transparentPaddingColor(c color.NRGBA) color.NRGBA { + c.A = 1 + return c +} + +// clipStampImage 裁剪印章图像 +// 入参: img 印章图像, box 印章区域, clip 裁剪区域 +// 返回: image.Image 裁剪后的印章图像 +func clipStampImage(img image.Image, box, clip Box) image.Image { + bounds := img.Bounds() + x0 := int(math.Floor(clip.X / box.W * float64(bounds.Dx()))) + y0 := int(math.Floor(clip.Y / box.H * float64(bounds.Dy()))) + x1 := int(math.Ceil((clip.X + clip.W) / box.W * float64(bounds.Dx()))) + y1 := int(math.Ceil((clip.Y + clip.H) / box.H * float64(bounds.Dy()))) + out := image.NewNRGBA(image.Rect(0, 0, x1-x0, y1-y0)) + draw.Draw(out, out.Bounds(), img, image.Pt(bounds.Min.X+x0, bounds.Min.Y+y0), draw.Src) + return out +} + +// stampImageWithTransparentWhite 处理印章图片白色底色 +// 入参: img 印章图片对象 +// 返回: image.Image 处理后的印章图片对象 +func stampImageWithTransparentWhite(img image.Image) image.Image { + if opaque, ok := img.(interface{ Opaque() bool }); ok && !opaque.Opaque() { + return img + } + bounds := img.Bounds() + out := image.NewNRGBA(bounds) + hasAlpha := false + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + c := color.NRGBAModel.Convert(img.At(x, y)).(color.NRGBA) + if c.A < 255 { + hasAlpha = true + } + out.SetNRGBA(x, y, c) + } + } + if hasAlpha { + return img + } + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + c := out.NRGBAAt(x, y) + if c.R >= 250 && c.G >= 250 && c.B >= 250 { + c.A = 0 + out.SetNRGBA(x, y, c) + } + } + } + return out +} + +// imageWithTransparentEdge 补齐透明图片边缘颜色 +// 入参: img 图片对象 +// 返回: image.Image 补齐后的图片对象, int 补齐像素数 +func imageWithTransparentEdge(img image.Image) (image.Image, int) { + if img == nil { + return img, 0 + } + bounds := img.Bounds() + w, h := bounds.Dx(), bounds.Dy() + if w == 0 || h == 0 { + return img, 0 + } + if src, ok := img.(*canvasimage.Image); ok && src.Mimetype == "image/jpeg" && src.Mask == nil { + return img, 0 + } + source := imagePixelSource(img) + if opaque, ok := source.(interface{ Opaque() bool }); ok && opaque.Opaque() { + return img, 0 + } + hasZero, hasVisible := false, false + src, ok := source.(*image.NRGBA) + if ok { + srcBounds := src.Bounds() + hasZero = src.Pix[3] == 0 + hasVisible = !hasZero + scan: + for y := srcBounds.Min.Y; y < srcBounds.Max.Y; y++ { + offset := src.PixOffset(srcBounds.Min.X, y) + 3 + for x := 0; x < w; x++ { + if (src.Pix[offset] == 0) != hasZero { + hasZero, hasVisible = true, true + break scan + } + offset += 4 + } + } + } else { + src = image.NewNRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + c := imageNRGBAAt(source, bounds.Min.X+x, bounds.Min.Y+y) + src.SetNRGBA(x, y, c) + if c.A == 0 { + hasZero = true + } else { + hasVisible = true + } + } + } + } + if !hasZero || !hasVisible { + return img, 0 + } + srcBounds := src.Bounds() + out := image.NewNRGBA(image.Rect(0, 0, w+2, h+2)) + draw.Draw(out, out.Bounds().Inset(1), src, srcBounds.Min, draw.Src) + for y := 0; y < h; y++ { + sy := srcBounds.Min.Y + y + offset := src.PixOffset(srcBounds.Min.X, sy) + 3 + for x := 0; x < w; x++ { + if src.Pix[offset] == 0 { + if edge, ok := transparentEdgeColor(src, srcBounds.Min.X+x, sy); ok { + out.SetNRGBA(x+1, y+1, edge) + } + } + offset += 4 + } + } + for x := 0; x < w; x++ { + out.SetNRGBA(x+1, 0, transparentPaddingColor(out.NRGBAAt(x+1, 1))) + out.SetNRGBA(x+1, h+1, transparentPaddingColor(out.NRGBAAt(x+1, h))) + } + for y := 0; y < h; y++ { + out.SetNRGBA(0, y+1, transparentPaddingColor(out.NRGBAAt(1, y+1))) + out.SetNRGBA(w+1, y+1, transparentPaddingColor(out.NRGBAAt(w, y+1))) + } + out.SetNRGBA(0, 0, transparentPaddingColor(out.NRGBAAt(1, 1))) + out.SetNRGBA(w+1, 0, transparentPaddingColor(out.NRGBAAt(w, 1))) + out.SetNRGBA(0, h+1, transparentPaddingColor(out.NRGBAAt(1, h))) + out.SetNRGBA(w+1, h+1, transparentPaddingColor(out.NRGBAAt(w, h))) + return out, 1 +} diff --git a/ofdgo_ink.go b/ofdgo_ink.go index 317df19..5df4491 100644 --- a/ofdgo_ink.go +++ b/ofdgo_ink.go @@ -17,8 +17,6 @@ package ofdgo import ( "fmt" "math" - - "github.com/tdewolff/canvas" ) // InkPoint 手写采样点,X和Y为毫米,Pressure为0到1的压力 @@ -36,24 +34,30 @@ func NewInk(points []InkPoint, width float64, pressure bool) (PathObject, error) if len(points) == 0 || !finite(width) || width <= 0 { return PathObject{}, fmt.Errorf("ink requires points and a positive finite width") } - path := &canvas.Path{} + var path GeometryPath stationary := true + x0, y0, x1, y1 := math.Inf(1), math.Inf(1), math.Inf(-1), math.Inf(-1) for i, point := range points { if !finite(point.X) || !finite(point.Y) || !finite(point.Pressure) || point.Pressure < 0 || point.Pressure > 1 { return PathObject{}, fmt.Errorf("invalid ink point %d", i) } stationary = stationary && point.X == points[0].X && point.Y == points[0].Y + radius := 0.0 + if pressure { + radius = width * point.Pressure / 2 + } + x0, y0 = math.Min(x0, point.X-radius), math.Min(y0, point.Y-radius) + x1, y1 = math.Max(x1, point.X+radius), math.Max(y1, point.Y+radius) if !pressure { + verb := GeometryLine if i == 0 { - path.MoveTo(point.X, point.Y) - } else { - path.LineTo(point.X, point.Y) + verb = GeometryMove } + path = append(path, GeometrySegment{Verb: verb, End: Point{X: point.X, Y: point.Y}}) continue } - radius := width * point.Pressure / 2 if radius > 0 { - path = path.Append(canvas.Circle(radius).Translate(point.X, point.Y)) + path = append(path, inkCircle(point.X, point.Y, radius)...) } if i == 0 { continue @@ -69,25 +73,32 @@ func NewInk(points []InkPoint, width float64, pressure bool) (PathObject, error) a := (r - radius) / length b := math.Sqrt(1 - a*a) nx, ny, mx, my := a*x-b*y, a*y+b*x, a*x+b*y, a*y-b*x - segment := &canvas.Path{} - segment.MoveTo(previous.X+r*mx, previous.Y+r*my) - segment.LineTo(point.X+radius*mx, point.Y+radius*my) - segment.LineTo(point.X+radius*nx, point.Y+radius*ny) - segment.LineTo(previous.X+r*nx, previous.Y+r*ny) - segment.Close() - path = path.Append(segment) + start := Point{X: previous.X + r*mx, Y: previous.Y + r*my} + path = append(path, + GeometrySegment{Verb: GeometryMove, End: start}, + GeometrySegment{Verb: GeometryLine, End: Point{X: point.X + radius*mx, Y: point.Y + radius*my}}, + GeometrySegment{Verb: GeometryLine, End: Point{X: point.X + radius*nx, Y: point.Y + radius*ny}}, + GeometrySegment{Verb: GeometryLine, End: Point{X: previous.X + r*nx, Y: previous.Y + r*ny}}, + GeometrySegment{Verb: GeometryClose, End: start}) } filled := pressure || stationary if !pressure && stationary { - path = canvas.Circle(width/2).Translate(points[0].X, points[0].Y) + path = inkCircle(points[0].X, points[0].Y, width/2) + x0, y0, x1, y1 = x0-width/2, y0-width/2, x1+width/2, y1+width/2 } - if path.Empty() { + if len(path) == 0 { return PathObject{}, fmt.Errorf("ink has no visible stroke") } - box := path.Bounds() padding := width / 2 - boundary := Box{X: box.X0 - padding, Y: box.Y0 - padding, W: math.Max(box.W(), width) + width, H: math.Max(box.H(), width) + width} - object := editorClipPath(path.Translate(-boundary.X, -boundary.Y)) + boundary := Box{X: x0 - padding, Y: y0 - padding, W: math.Max(x1-x0, width) + width, H: math.Max(y1-y0, width) + width} + for i := range path { + path[i].End.X -= boundary.X + path[i].End.Y -= boundary.Y + } + object, err := geometryClipPath(path) + if err != nil { + return PathObject{}, err + } object.Boundary = editorBoxString(boundary) stroke := !filled object.Fill, object.Stroke = &filled, &stroke @@ -99,3 +110,16 @@ func NewInk(points []InkPoint, width float64, pressure bool) (PathObject, error) } return object, nil } + +// inkCircle 使用两个半圆弧构造笔迹圆点,不扁平化圆形轮廓 +// 入参: x、y 圆心, radius 半径 +// 返回: GeometryPath 圆形路径 +func inkCircle(x, y, radius float64) GeometryPath { + start := Point{X: x + radius, Y: y} + return GeometryPath{ + {Verb: GeometryMove, End: start}, + {Verb: GeometryArc, End: Point{X: x - radius, Y: y}, RadiusX: radius, RadiusY: radius, Sweep: true}, + {Verb: GeometryArc, End: start, RadiusX: radius, RadiusY: radius, Sweep: true}, + {Verb: GeometryClose, End: start}, + } +} diff --git a/ofdgo_paint.go b/ofdgo_paint.go index c8113ef..b5d13e6 100644 --- a/ofdgo_paint.go +++ b/ofdgo_paint.go @@ -16,13 +16,17 @@ package ofdgo import ( "image/color" - "math" "strconv" "strings" - - "github.com/tdewolff/canvas" ) +// ResolveColor 按文档颜色空间和调色板解析颜色,返回预乘透明度的RGBA +// 入参: value 颜色分量, index 调色板索引, space 颜色空间标识, alpha 透明度 +// 返回: color.RGBA 标准颜色 +func (r *Renderer) ResolveColor(value string, index *int, space string, alpha *int) color.RGBA { + return colorToRGBA(r.parseColorWithAlpha(value, index, space, alpha)) +} + // parseColorWithAlpha 解析带透明度的颜色 // 入参: value 颜色值, index 调色板索引, space 颜色空间标识, alpha 透明度 // 返回: color.Color 颜色对象 @@ -180,25 +184,6 @@ func (r *Renderer) parseFillColor(fillColor *FillColor) color.Color { return r.parseColorWithAlpha(fillColor.Value, fillColor.Index, fillColor.ColorSpace, fillColor.Alpha) } -// parseFillPaint 解析填充画刷 -// 入参: fillColor 填充颜色节点, x X坐标, y Y坐标, pageH 页面高度 -// 返回: any 填充画刷 -func (r *Renderer) parseFillPaint(fillColor *FillColor, x, y, pageH float64) any { - if fillColor == nil { - return nil - } - if fillColor.Pattern != nil { - return nil - } - if gradient := r.parseAxialShdGradient(fillColor.AxialShd, fillColor.Alpha, x, y, pageH); gradient != nil { - return newShdPaint(gradient, fillColor.AxialShd.Extend, fillColor.AxialShd.MapType, fillColor.AxialShd.MapUnit) - } - if paint := r.parseRadialShdPaint(fillColor.RadialShd, fillColor.Alpha, x, y, pageH); paint != nil { - return paint - } - return r.parseFillColor(fillColor) -} - // parseStrokeColor 解析勾边颜色 // 入参: strokeColor 勾边颜色节点 // 返回: color.Color 颜色对象 @@ -206,13 +191,6 @@ func (r *Renderer) parseStrokeColor(strokeColor *StrokeColor) color.Color { return r.parseFillColor((*FillColor)(strokeColor)) } -// parseStrokePaint 解析勾边画刷 -// 入参: strokeColor 勾边颜色节点, x X坐标, y Y坐标, pageH 页面高度 -// 返回: any 勾边画刷 -func (r *Renderer) parseStrokePaint(strokeColor *StrokeColor, x, y, pageH float64) any { - return r.parseFillPaint((*FillColor)(strokeColor), x, y, pageH) -} - // patternPaint 底纹画刷 type patternPaint struct { *Pattern @@ -241,11 +219,12 @@ func (r *Renderer) parseShdColor(segments []ShdSegment, alpha *int) color.Color return r.parseColorWithAlpha(c.Value, c.Index, c.ColorSpace, mergeAlpha(c.Alpha, alpha)) } -// parseShdSegments 解析渐变分段 +// GradientStops 解析OFD渐变分段的位置与透明度,返回独立数据并保留原分段顺序 +// GradientStops 解析OFD渐变分段的位置与透明度,返回独立数据并保留原分段顺序 // 入参: segments 渐变分段, alpha 透明度 -// 返回: canvas.Grad 渐变分段 -func (r *Renderer) parseShdSegments(segments []ShdSegment, alpha *int) canvas.Grad { - gradient := canvas.NewGradient() +// 返回: []RasterStop 后端无关的渐变分段 +func (r *Renderer) GradientStops(segments []ShdSegment, alpha *int) []RasterStop { + var gradient []RasterStop position, step := 0.0, 0.0 for i, segment := range segments { if !segment.positionMissing { @@ -267,7 +246,7 @@ func (r *Renderer) parseShdSegments(segments []ShdSegment, alpha *int) canvas.Gr offset := position position += step segmentAlpha := mergeAlpha(segment.Color.Alpha, alpha) - gradient.Add(offset, colorToRGBA(r.parseColorWithAlpha(segment.Color.Value, segment.Color.Index, segment.Color.ColorSpace, segmentAlpha))) + gradient = append(gradient, RasterStop{Offset: offset, Color: r.ResolveColor(segment.Color.Value, segment.Color.Index, segment.Color.ColorSpace, segmentAlpha)}) } if len(gradient) == 0 { return nil @@ -275,78 +254,6 @@ func (r *Renderer) parseShdSegments(segments []ShdSegment, alpha *int) canvas.Gr return gradient } -// parseAxialShdGradient 解析轴向渐变 -// 入参: axialShd 轴向渐变节点, alpha 透明度, x X坐标, y Y坐标, pageH 页面高度 -// 返回: canvas.Gradient 渐变对象 -func (r *Renderer) parseAxialShdGradient(axialShd *AxialShd, alpha *int, x, y, pageH float64) canvas.Gradient { - if axialShd == nil { - return nil - } - start := parseFloats(axialShd.StartPoint) - end := parseFloats(axialShd.EndPoint) - if len(start) < 2 || len(end) < 2 { - return nil - } - gradient := r.parseShdSegments(axialShd.Segment, alpha) - if gradient == nil { - return nil - } - startPoint := canvas.Point{X: x + start[0], Y: pageH - (y + start[1])} - endPoint := canvas.Point{X: x + end[0], Y: pageH - (y + end[1])} - if startPoint.Equals(endPoint) { - return nil - } - return gradient.ToLinear(startPoint, endPoint) -} - -// axialShdClip 获取轴向渐变的延伸裁剪区域 -// 入参: gradient 轴向渐变, extend 延伸方向, bounds 可见区域 -// 返回: *canvas.Path 裁剪区域 -func axialShdClip(gradient *canvas.LinearGradient, extend int, bounds canvas.Rect) *canvas.Path { - if extend == 3 { - return nil - } - d := gradient.End.Sub(gradient.Start) - axis := canvas.Matrix{{d.X, -d.Y, gradient.Start.X}, {d.Y, d.X, gradient.Start.Y}} - area := bounds.Transform(axis.Inv()) - if extend&1 == 0 { - area.X0 = math.Max(area.X0, 0) - } - if extend&2 == 0 { - area.X1 = math.Min(area.X1, 1) - } - if area.X1 <= area.X0 { - return &canvas.Path{} - } - return area.ToPath().Transform(axis) -} - -// parseRadialShdPaint 解析径向渐变画刷 -// 入参: radialShd 径向渐变节点, alpha 透明度, x X坐标, y Y坐标, pageH 页面高度 -// 返回: *shdPaint 渐变画刷 -func (r *Renderer) parseRadialShdPaint(radialShd *RadialShd, alpha *int, x, y, pageH float64) *shdPaint { - if radialShd == nil || radialShd.EndRadius <= 0 { - return nil - } - start := parseFloats(radialShd.StartPoint) - end := parseFloats(radialShd.EndPoint) - if len(start) < 2 || len(end) < 2 { - return nil - } - gradient := r.parseShdSegments(radialShd.Segment, alpha) - if gradient == nil { - return nil - } - startPoint := canvas.Point{X: x + start[0], Y: pageH - (y + start[1])} - endPoint := canvas.Point{X: x + end[0], Y: pageH - (y + end[1])} - paint := newShdPaint(gradient.ToRadial(startPoint, radialShd.StartRadius, endPoint, radialShd.EndRadius), radialShd.Extend, radialShd.MapType, radialShd.MapUnit) - if e := radialShd.Eccentricity; 0 < e && e < 1 { - paint.view = canvas.Identity.Translate(startPoint.X, startPoint.Y).Rotate(-radialShd.Angle).Scale(1, math.Sqrt(1-e*e)).Translate(-startPoint.X, -startPoint.Y) - paint.gradient = gradient.ToRadial(startPoint, radialShd.StartRadius, paint.view.Inv().Dot(endPoint), radialShd.EndRadius) - } - return paint -} - // colorToRGBA 转换颜色对象 // 入参: c 颜色对象 // 返回: color.RGBA RGBA颜色 diff --git a/ofdgo_render.go b/ofdgo_render.go new file mode 100644 index 0000000..c89a85e --- /dev/null +++ b/ofdgo_render.go @@ -0,0 +1,115 @@ +// 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 + +// RenderPage 编译页面为与绘图库无关的只读绘制数据 +// 入参: page 页面内容 +// 返回: *RasterPage 绘制页面, error 错误信息 +func (r *Renderer) RenderPage(page *PageContent) (*RasterPage, error) { + return r.CompilePage(page) +} + +// GetPageBox 获取页面物理区域 +// 入参: page 页面内容 +// 返回: Box 区域, error 错误信息 +func (r *Renderer) GetPageBox(page *PageContent) (Box, error) { + area, err := r.Reader.resolvePageArea(page.Area, page.Template) + if err != nil { + return Box{}, err + } + boxStr := area.PhysicalBox + if boxStr == "" { + boxStr = area.ApplicationBox + } + if boxStr == "" { + boxStr = area.ContentBox + } + if boxStr == "" { + boxStr = "0 0 210 297" + } + return ParseBox(boxStr) +} + +// PageLinks 获取页面、模板和可见注释的点击链接,包含复杂区域、组合图元和附件动作 +// 入参: page 页面内容 +// 返回: []PageLink 页面链接, error 错误信息 +func (r *Renderer) PageLinks(page *PageContent) ([]PageLink, error) { + box, err := r.GetPageBox(page) + if err != nil { + return nil, err + } + sources := r.pageActionSources(page, box) + if r.RenderAnnotations { + sources = append(sources, r.annotationActionSources(r.Reader.Annots[page.ID])...) + } + var links []PageLink + var bookmarks map[string]Dest + for sourceIndex, source := range sources { + start := len(links) + for _, action := range source.Actions { + if action.Event != "CLICK" { + continue + } + box, path, err := r.actionLinkRegion(source, action) + if err != nil { + return nil, err + } + if box.W <= 0 || box.H <= 0 { + continue + } + link := PageLink{Box: box, Path: path} + if action.Goto != nil { + if bookmarks == nil { + doc, err := r.Reader.Doc() + if err != nil { + return nil, err + } + bookmarks = make(map[string]Dest, len(doc.Bookmarks.Bookmark)) + for _, bookmark := range doc.Bookmarks.Bookmark { + bookmarks[bookmark.Name] = bookmark.Dest + } + } + if dest := gotoDest(action.Goto, bookmarks); dest != nil { + value := *dest + link.Dest = &value + links = append(links, link) + } + } else if action.URI != nil && action.URI.URI != "" { + link.URI = resolveActionURI(*action.URI) + links = append(links, link) + } else if action.GotoA != nil { + link.Attachment = action.GotoA.AttachID + links = append(links, link) + } + } + if len(links)-start > 1 { + for i := start; i < len(links); i++ { + links[i].Group = sourceIndex + 1 + } + } + } + return links, nil +} + +// RenderPageByIndex 按索引渲染页面 +// 入参: index 页面索引,从0开始 +// 返回: *RasterPage 绘制页面, error 错误信息 +func (r *Renderer) RenderPageByIndex(index int) (*RasterPage, error) { + page, err := r.Reader.PageContentByIndex(index) + if err != nil { + return nil, err + } + return r.RenderPage(page) +} diff --git a/ofdgo_renderer_bounds.go b/ofdgo_render_bounds.go similarity index 97% rename from ofdgo_renderer_bounds.go rename to ofdgo_render_bounds.go index 98d277e..bbd435e 100644 --- a/ofdgo_renderer_bounds.go +++ b/ofdgo_render_bounds.go @@ -48,7 +48,10 @@ func (r *Renderer) AnnotationGeometry(annotation Annotation) (Box, []ObjectConto if action.Event != "CLICK" { continue } - region, path := actionLinkRegion(source, action) + region, path, err := r.actionLinkRegion(source, action) + if err != nil { + return Box{}, nil, err + } if region.W <= 0 || region.H <= 0 { continue } diff --git a/ofdgo_renderer_output.go b/ofdgo_render_output.go similarity index 100% rename from ofdgo_renderer_output.go rename to ofdgo_render_output.go diff --git a/ofdgo_renderer_preview.go b/ofdgo_render_preview.go similarity index 100% rename from ofdgo_renderer_preview.go rename to ofdgo_render_preview.go diff --git a/ofdgo_renderer_svg.go b/ofdgo_render_svg.go similarity index 100% rename from ofdgo_renderer_svg.go rename to ofdgo_render_svg.go diff --git a/ofdgo_renderer.go b/ofdgo_renderer.go index 4f34da1..c4d5b2a 100644 --- a/ofdgo_renderer.go +++ b/ofdgo_renderer.go @@ -17,15 +17,15 @@ package ofdgo import ( "image" "io/fs" - - "github.com/tdewolff/canvas" ) // Renderer 渲染器实现 // 通过NewRenderer创建,实例及共享的Reader需串行使用 // OnPageText可选,接收页面绘制时同步提取的文字,不包含图案和签名外观 // OnExportProgress可选,同步回报文档导出的已处理页数及总页数,返回错误则停止,页数完成不代表写入成功 +// TransparentBackground关闭页面白底,供嵌套图案和印章保持透明背景 type Renderer struct { + canvasFontState Reader *Reader DPI float64 RenderAnnotations bool @@ -33,18 +33,6 @@ type Renderer struct { OnExportProgress func(completed, total int) error DrawParams map[string]*DrawParam CompositeGraphicUnits map[string]*CompositeGraphicUnit - fontFamily *canvas.FontFamily - defaultFontLoaded bool - fontMap map[string]*canvas.FontFamily - fontGIDMap map[string]map[uint16]rune - fontCIDMap map[string]map[uint16]rune - 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 templatePageCache map[string]*PageContent imageCache map[string]image.Image fontDirs []string @@ -53,6 +41,9 @@ type Renderer struct { pageText *PageText textOnly bool backends RenderBackends + resolvedFonts map[resolvedFontKey]resolvedFontResult + renderError error + TransparentBackground bool } // RendererOption 渲染器配置选项 @@ -72,195 +63,6 @@ func (r *Renderer) FontSources() ([]string, []fs.FS) { return append([]string(nil), r.fontDirs...), append([]fs.FS(nil), r.fontFS...) } -// resetFontCache 重置字体匹配、加载和字形缓存 -func (r *Renderer) resetFontCache() { - r.fontMap = make(map[string]*canvas.FontFamily) - r.fontGIDMap = make(map[string]map[uint16]rune) - r.fontCIDMap = make(map[string]map[uint16]rune) - r.fontCache = make(map[fontCacheKey]*canvas.FontFamily) - r.svgFontCache = make(map[*canvas.Font]SVGFont) - r.fontSourceCache = make(map[string][]fontSource) - r.fontSourceUsed = make(map[string]fontSource) - r.fontDirCandidates = make(map[string][]fontFileCandidate) - r.fontFSCandidates = make(map[int][]fontFileCandidate) - r.textGlyphPathCache = make(map[textGlyphPathCacheKey]textGlyphPathCacheValue) -} - -// RenderPage 编译页面为与绘图库无关的只读绘制数据 -// 入参: page 页面内容 -// 返回: *RasterPage 绘制页面, error 错误信息 -func (r *Renderer) RenderPage(page *PageContent) (*RasterPage, error) { - return r.CompilePage(page) -} - -// renderPage 渲染特定页面内容 -// 入参: page 页面内容 -// 返回: *canvas.Canvas 画布实例, error 错误信息 -func (r *Renderer) renderPage(page *PageContent) (*canvas.Canvas, error) { - box, err := r.GetPageBox(page) - if err != nil { - return nil, err - } - width, height := box.W, box.H - c := canvas.New(width, height) - ctx := canvas.NewContext(c) - if err := r.renderPageToContext(ctx, page, true); err != nil { - return nil, err - } - return c, nil -} - -// GetPageBox 获取页面物理区域 -// 入参: page 页面内容 -// 返回: Box 区域, error 错误信息 -func (r *Renderer) GetPageBox(page *PageContent) (Box, error) { - area, err := r.Reader.resolvePageArea(page.Area, page.Template) - if err != nil { - return Box{}, err - } - boxStr := area.PhysicalBox - if boxStr == "" { - boxStr = area.ApplicationBox - } - if boxStr == "" { - boxStr = area.ContentBox - } - if boxStr == "" { - boxStr = "0 0 210 297" - } - return ParseBox(boxStr) -} - -// renderCanvasContext 渲染页面到默认解释器上下文 -// 入参: ctx 画布上下文, page 页面内容 -// 返回: error 错误信息 -func (r *Renderer) renderCanvasContext(ctx *canvas.Context, page *PageContent) error { - renderer := *r - renderer.decodeImages = true - return renderer.renderPageToContext(ctx, page, true) -} - -// renderPageToContext 渲染页面到指定上下文 -// 入参: ctx 画布上下文, page 页面内容, drawBackground 是否绘制页面背景 -// 返回: error 错误信息 -func (r *Renderer) renderPageToContext(ctx *canvas.Context, page *PageContent, drawBackground bool) error { - box, err := r.GetPageBox(page) - if err != nil { - return err - } - if r.OnPageText != nil { - renderer := *r - renderer.pageText = &PageText{} - r = &renderer - } - pageH := box.H - if drawBackground { - ctx.SetFillColor(canvas.White) - ctx.DrawPath(0, 0, canvas.Rectangle(box.W, box.H)) - } - for order := range 3 { - if r.Reader.doc != nil { - for _, tplRef := range page.Template { - kind := tplRef.ZOrder - if kind == "" { - kind = "Background" - } - if layerOrder(kind) == order { - r.renderTemplate(ctx, tplRef.TemplateID, pageH) - } - } - } - r.renderLayers(ctx, page.Content.Layer, pageH, order) - } - if r.RenderAnnotations { - r.renderAnnotations(ctx, page.ID, pageH) - } - if stamps, ok := r.Reader.Stamps[page.ID]; ok { - for _, stamp := range stamps { - r.renderStamp(ctx, stamp, pageH) - } - } - if r.OnPageText != nil { - r.OnPageText(page, r.pageText) - } - return nil -} - -// PageLinks 获取页面、模板和可见注释的点击链接,包含复杂区域、组合图元和附件动作 -// 入参: page 页面内容 -// 返回: []PageLink 页面链接, error 错误信息 -func (r *Renderer) PageLinks(page *PageContent) ([]PageLink, error) { - box, err := r.GetPageBox(page) - if err != nil { - return nil, err - } - sources := r.pageActionSources(page, box) - if r.RenderAnnotations { - sources = append(sources, r.annotationActionSources(r.Reader.Annots[page.ID])...) - } - var links []PageLink - var bookmarks map[string]Dest - for sourceIndex, source := range sources { - start := len(links) - for _, action := range source.Actions { - if action.Event != "CLICK" { - continue - } - box, path := actionLinkRegion(source, action) - if box.W <= 0 || box.H <= 0 { - continue - } - link := PageLink{Box: box, Path: path} - if action.Goto != nil { - if bookmarks == nil { - doc, err := r.Reader.Doc() - if err != nil { - return nil, err - } - bookmarks = make(map[string]Dest, len(doc.Bookmarks.Bookmark)) - for _, bookmark := range doc.Bookmarks.Bookmark { - bookmarks[bookmark.Name] = bookmark.Dest - } - } - if dest := gotoDest(action.Goto, bookmarks); dest != nil { - value := *dest - link.Dest = &value - links = append(links, link) - } - } else if action.URI != nil && action.URI.URI != "" { - link.URI = resolveActionURI(*action.URI) - links = append(links, link) - } else if action.GotoA != nil { - link.Attachment = action.GotoA.AttachID - links = append(links, link) - } - } - if len(links)-start > 1 { - for i := start; i < len(links); i++ { - links[i].Group = sourceIndex + 1 - } - } - } - return links, nil -} - -// RenderPageByIndex 按索引渲染页面 -// 入参: index 页面索引,从0开始 -// 返回: *RasterPage 绘制页面, error 错误信息 -func (r *Renderer) RenderPageByIndex(index int) (*RasterPage, error) { - page, err := r.Reader.PageContentByIndex(index) - if err != nil { - return nil, err - } - return r.RenderPage(page) -} - -// initCommon 初始化公共资源 -func (r *Renderer) initCommon() { - r.fontFamily = canvas.NewFontFamily("default") - r.defaultFontLoaded = r.loadDefaultFonts() -} - // childRenderer 创建继承当前配置的子渲染器 // 入参: reader 子阅读器 // 返回: *Renderer 子渲染器 @@ -268,6 +70,7 @@ func (r *Renderer) childRenderer(reader *Reader) *Renderer { opts := []RendererOption{ WithDPI(r.DPI), WithAnnotations(r.RenderAnnotations), + WithRenderBackends(r.backends), } if len(r.fontDirs) > 0 { opts = append(opts, WithFontDirs(r.fontDirs...)) @@ -277,5 +80,40 @@ func (r *Renderer) childRenderer(reader *Reader) *Renderer { } renderer := NewRenderer(reader, opts...) renderer.decodeImages = r.decodeImages + renderer.TransparentBackground = r.TransparentBackground return renderer } + +// loadTemplate 加载模板页面并复用解析结果 +// 入参: templateID 模板ID +// 返回: *PageContent 模板页面 +func (r *Renderer) loadTemplate(templateID string) *PageContent { + if page := r.templatePageCache[templateID]; page != nil { + return page + } + for _, tpl := range r.Reader.doc.CommonData.TemplatePage { + if tpl.ID != templateID { + continue + } + page, err := r.Reader.PageContent(Page{BaseLoc: tpl.BaseLoc}) + if err != nil { + return nil + } + r.templatePageCache[templateID] = page + return page + } + return nil +} + +// layerOrder 获取图层类型的绘制顺序 +// 入参: kind 图层类型 +// 返回: int 绘制顺序 +func layerOrder(kind string) int { + switch kind { + case "Background": + return 0 + case "Foreground": + return 2 + } + return 1 +} diff --git a/ofdgo_canvas.go b/ofdgo_renderer_canvas.go similarity index 100% rename from ofdgo_canvas.go rename to ofdgo_renderer_canvas.go diff --git a/ofdgo_renderer_clip.go b/ofdgo_renderer_clip.go deleted file mode 100644 index acacc84..0000000 --- a/ofdgo_renderer_clip.go +++ /dev/null @@ -1,65 +0,0 @@ -// 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" - - "github.com/tdewolff/canvas" -) - -// clipRenderer 收集图形和文字的绘制轮廓 -type clipRenderer struct { - path *canvas.Path - clip *canvas.Path -} - -// Size 返回不限定边界的裁剪画布尺寸 -// 返回: float64 宽度, float64 高度 -func (r *clipRenderer) Size() (float64, float64) { - return 0, 0 -} - -// RenderPath 合并路径填充及描边轮廓 -// 入参: path 路径, style 绘制样式, m 变换矩阵 -func (r *clipRenderer) RenderPath(path *canvas.Path, style canvas.Style, m canvas.Matrix) { - if style.HasFill() { - r.add(applyClipPath(path.Copy().Transform(m), r.clip)) - } - if style.HasStroke() { - p := path.Dash(style.DashOffset, style.Dashes...).Stroke(style.StrokeWidth, style.StrokeCapper, style.StrokeJoiner, canvas.Tolerance) - r.add(applyClipPath(p.Transform(m), r.clip)) - } -} - -// add 合并裁剪轮廓 -// 入参: path 裁剪路径 -func (r *clipRenderer) add(path *canvas.Path) { - if r.path == nil { - r.path = path - } else { - r.path = unionClipPath(r.path, path) - } -} - -// RenderText 合并文字字形轮廓 -// 入参: text 文字, m 变换矩阵 -func (r *clipRenderer) RenderText(text *canvas.Text, m canvas.Matrix) { - text.RenderTo(r, m, 0) -} - -// RenderImage 裁剪区不包含图像对象 -// 入参: img 图像, m 变换矩阵 -func (r *clipRenderer) RenderImage(img image.Image, m canvas.Matrix) {} diff --git a/ofdgo_renderer_clip_canvas.go b/ofdgo_renderer_clip_canvas.go new file mode 100644 index 0000000..0a858f1 --- /dev/null +++ b/ofdgo_renderer_clip_canvas.go @@ -0,0 +1,249 @@ +// 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" + + "github.com/tdewolff/canvas" +) + +// clipRenderer 收集图形和文字的绘制轮廓 +type clipRenderer struct { + path *canvas.Path + clip *canvas.Path +} + +// Size 返回不限定边界的裁剪画布尺寸 +// 返回: float64 宽度, float64 高度 +func (r *clipRenderer) Size() (float64, float64) { + return 0, 0 +} + +// RenderPath 合并路径填充及描边轮廓 +// 入参: path 路径, style 绘制样式, m 变换矩阵 +func (r *clipRenderer) RenderPath(path *canvas.Path, style canvas.Style, m canvas.Matrix) { + if style.HasFill() { + r.add(applyClipPath(path.Copy().Transform(m), r.clip)) + } + if style.HasStroke() { + p := path.Dash(style.DashOffset, style.Dashes...).Stroke(style.StrokeWidth, style.StrokeCapper, style.StrokeJoiner, canvas.Tolerance) + r.add(applyClipPath(p.Transform(m), r.clip)) + } +} + +// add 合并裁剪轮廓 +// 入参: path 裁剪路径 +func (r *clipRenderer) add(path *canvas.Path) { + if r.path == nil { + r.path = path + } else { + r.path = unionClipPath(r.path, path) + } +} + +// RenderText 合并文字字形轮廓 +// 入参: text 文字, m 变换矩阵 +func (r *clipRenderer) RenderText(text *canvas.Text, m canvas.Matrix) { + text.RenderTo(r, m, 0) +} + +// RenderImage 裁剪区不包含图像对象 +// 入参: img 图像, m 变换矩阵 +func (r *clipRenderer) RenderImage(img image.Image, m canvas.Matrix) {} + +// buildObjectClipPath 构建对象裁剪路径并应用父级变换 +// 入参: 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 !boundaryInCTM && parentCTM != nil { + objectCTM = parentCTM.Multiply(objectCTM) + } + p := r.buildClipPath(clips, pageH, bx, by, objectCTM) + if p != nil && boundaryInCTM && parentCTM != nil { + p = p.Transform(canvas.Matrix{ + {parentCTM.a, -parentCTM.c, parentCTM.c*pageH + parentCTM.e}, + {-parentCTM.b, parentCTM.d, pageH*(1-parentCTM.d) - parentCTM.f}, + }) + } + return p +} + +// buildClipPath 构建裁剪路径 +// 入参: clips 裁剪对象, pageH 页面高度, bx 边界X坐标, by 边界Y坐标, objectCTM 对象CTM +// 返回: *canvas.Path 路径对象 +func (r *Renderer) buildClipPath(clips *Clips, pageH float64, bx, by float64, objectCTM Matrix) *canvas.Path { + if clips == nil { + return nil + } + var p *canvas.Path + for _, clip := range clips.Clip { + renderer := &clipRenderer{} + for _, area := range clip.Area { + areaCTM := NewMatrix(area.CTM) + if clips.TransFlag == nil || *clips.TransFlag { + areaCTM = objectCTM.Multiply(areaCTM) + } + for _, pathObj := range area.Path { + ctm := areaCTM.Multiply(NewMatrix(pathObj.CTM)) + cp := r.buildPath(pathObj, pageH, ctm, true) + cp.Translate(bx, -by) + cp.Close() + if pathObj.Rule == "Even-Odd" { + cp = cp.Settle(canvas.EvenOdd) + } + renderer.add(cp) + } + for _, textObj := range area.Text { + box, _ := ParseBox(textObj.Boundary) + ctm := NewMatrix(textObj.CTM) + m := areaCTM.Multiply(TranslationMatrix(box.X, box.Y)).Multiply(ctm) + ctx := canvas.NewContext(renderer) + ctx.SetView(canvas.Matrix{{m.a, -m.c, bx + m.e}, {-m.b, m.d, pageH - by - m.f}}) + renderer.clip = r.buildObjectClipPath(textObj.Clips, pageH, box.X, box.Y, ctm, &areaCTM, true) + if renderer.clip != nil { + renderer.clip = renderer.clip.Translate(bx, -by) + } + textObj.Boundary, textObj.CTM = "", "" + textObj.Clips = nil + textObj.Alpha = nil + textObj.FillColor = &FillColor{Value: "0 0 0"} + textObj.StrokeColor = &StrokeColor{Value: "0 0 0"} + textRenderer := *r + textRenderer.pageText = nil + textRenderer.textOnly = false + textRenderer.renderText(ctx, textObj, 0, r.drawParamDefaults(area.DrawParam, nil), nil, false, nil) + if textRenderer.renderError != nil { + r.renderError = textRenderer.renderError + return nil + } + } + } + if renderer.path == nil { + renderer.path = &canvas.Path{} + } + p = intersectClipPath(p, renderer.path) + } + return p +} + +// intersectClipPath 求裁剪路径交集 +// 入参: parent 父级裁剪路径, current 当前裁剪路径 +// 返回: *canvas.Path 相交后的裁剪路径 +func intersectClipPath(parent, current *canvas.Path) *canvas.Path { + if parent == nil { + return current + } + if current == nil { + return parent + } + parentRect, parentOK := rectangularPath(parent) + currentRect, currentOK := rectangularPath(current) + if parentOK && currentOK { + return parentRect.And(currentRect).ToPath() + } + if parent.Empty() || current.Empty() { + return &canvas.Path{} + } + if parentOK && parentRect.Contains(current.FastBounds()) { + return current + } + if currentOK && currentRect.Contains(parent.FastBounds()) { + return parent + } + return parent.And(current) +} + +// unionClipPath 合并裁剪区域 +// 入参: left 左侧裁剪路径, right 右侧裁剪路径 +// 返回: *canvas.Path 合并后的裁剪路径 +func unionClipPath(left, right *canvas.Path) *canvas.Path { + leftRect, leftOK := rectangularPath(left) + rightRect, rightOK := rectangularPath(right) + if leftOK && rightOK { + bounds := leftRect.Add(rightRect) + area := leftRect.Area() + rightRect.Area() - leftRect.And(rightRect).Area() + if canvas.Equal(area, bounds.Area()) { + return bounds.ToPath() + } + } + return left.Or(right) +} + +// applyClipPath 应用裁剪路径 +// 入参: path 绘制路径, clip 裁剪路径 +// 返回: *canvas.Path 裁剪后的绘制路径 +func applyClipPath(path, clip *canvas.Path) *canvas.Path { + if path == nil || clip == nil { + return path + } + if path.Empty() || clip.Empty() { + return &canvas.Path{} + } + if rect, ok := rectangularPath(clip); ok { + bounds := path.FastBounds() + if rect.Contains(bounds) { + return path + } + if !rect.Overlaps(bounds) { + return &canvas.Path{} + } + if pathRect, ok := rectangularPath(path); ok { + return pathRect.And(rect).ToPath() + } + } + return path.And(clip) +} + +// rectangularPath 获取矩形路径区域 +// 入参: path 路径对象 +// 返回: canvas.Rect 矩形区域, bool 是否为矩形 +func rectangularPath(path *canvas.Path) (canvas.Rect, bool) { + if path == nil || path.HasSubpaths() || !path.Closed() { + return canvas.Rect{}, false + } + data := path.Data() + for i := 0; i < len(data); i += 4 { + if i+4 > len(data) || (data[i] != canvas.MoveToCmd && data[i] != canvas.LineToCmd && data[i] != canvas.CloseCmd) { + return canvas.Rect{}, false + } + } + points := path.Coords() + if len(points) != 5 || !points[0].Equals(points[4]) { + return canvas.Rect{}, false + } + rect := path.FastBounds() + corners := 0 + for _, point := range points[:4] { + corner := 0 + switch { + case canvas.Equal(point.X, rect.X0) && canvas.Equal(point.Y, rect.Y0): + corner = 1 + case canvas.Equal(point.X, rect.X1) && canvas.Equal(point.Y, rect.Y0): + corner = 2 + case canvas.Equal(point.X, rect.X1) && canvas.Equal(point.Y, rect.Y1): + corner = 4 + case canvas.Equal(point.X, rect.X0) && canvas.Equal(point.Y, rect.Y1): + corner = 8 + default: + return canvas.Rect{}, false + } + if corners&corner != 0 { + return canvas.Rect{}, false + } + corners |= corner + } + return rect, corners == 15 +} diff --git a/ofdgo_canvas_compile.go b/ofdgo_renderer_compile_canvas.go similarity index 100% rename from ofdgo_canvas_compile.go rename to ofdgo_renderer_compile_canvas.go diff --git a/ofdgo_renderer_font_canvas.go b/ofdgo_renderer_font_canvas.go new file mode 100644 index 0000000..50eeb28 --- /dev/null +++ b/ofdgo_renderer_font_canvas.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" + "path" + + "github.com/tdewolff/canvas" + "github.com/tdewolff/font" +) + +// 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.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.fontFamily == nil { + r.initCanvasFonts() + } + if r.defaultFontLoaded { + return ResolvedFont{Data: fontSFNTData(r.fontFamily.Face(12, canvas.FontRegular).Font.SFNT)} + } + return ResolvedFont{} +} + +// OpenFont 解析独立SFNT数据,度量与原有编辑排版保持一致 +// 入参: data 字体数据 +// 返回: FontMetrics 字体度量, error 解析错误 +func (CanvasBackend) OpenFont(data []byte) (FontMetrics, error) { + sfnt, err := font.ParseSFNT(data, 0) + if err != nil { + return nil, err + } + return canvasFontMetrics{sfnt}, nil +} + +// canvasFontMetrics 保留默认字体度量,写出时不更新原始时间 +type canvasFontMetrics struct { + *font.SFNT +} + +// Write 返回字体数据,不修改字体元信息 +// 返回: []byte 独立SFNT数据 +func (f canvasFontMetrics) Write() []byte { + return fontSFNTData(f.SFNT) +} + +// canvasFontState 保存默认适配器的字体与字形缓存,不向后端接口暴露绘图库类型 +type canvasFontState struct { + fontFamily *canvas.FontFamily + defaultFontLoaded bool + fontMap map[string]*canvas.FontFamily + fontGIDMap map[string]map[uint16]rune + fontCIDMap map[string]map[uint16]rune + 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 +} + +// resetFontCache 重置字体匹配、加载和字形缓存 +func (r *Renderer) resetFontCache() { + r.resolvedFonts = make(map[resolvedFontKey]resolvedFontResult) + r.fontMap = make(map[string]*canvas.FontFamily) + r.fontGIDMap = make(map[string]map[uint16]rune) + r.fontCIDMap = make(map[string]map[uint16]rune) + r.fontCache = make(map[fontCacheKey]*canvas.FontFamily) + r.svgFontCache = make(map[*canvas.Font]SVGFont) + r.fontSourceCache = make(map[string][]fontSource) + r.fontSourceUsed = make(map[string]fontSource) + r.fontDirCandidates = make(map[string][]fontFileCandidate) + r.fontFSCandidates = make(map[int][]fontFileCandidate) + r.textGlyphPathCache = make(map[textGlyphPathCacheKey]textGlyphPathCacheValue) +} + +// initCanvasFonts 初始化Canvas默认字体 +func (r *Renderer) initCanvasFonts() { + r.fontFamily = canvas.NewFontFamily("default") + r.defaultFontLoaded = r.loadDefaultFonts() +} diff --git a/ofdgo_font_native.go b/ofdgo_renderer_font_canvas_native.go similarity index 100% rename from ofdgo_font_native.go rename to ofdgo_renderer_font_canvas_native.go diff --git a/ofdgo_font_wasm.go b/ofdgo_renderer_font_canvas_wasm.go similarity index 100% rename from ofdgo_font_wasm.go rename to ofdgo_renderer_font_canvas_wasm.go diff --git a/ofdgo_font_loader.go b/ofdgo_renderer_font_loader_canvas.go similarity index 87% rename from ofdgo_font_loader.go rename to ofdgo_renderer_font_loader_canvas.go index cc985fd..d018cbb 100644 --- a/ofdgo_font_loader.go +++ b/ofdgo_renderer_font_loader_canvas.go @@ -67,55 +67,51 @@ func (r *Renderer) loadFont(fontID string) *canvas.FontFamily { if ff, ok := r.fontMap[fontID]; ok { return ff } - var defaultFont *canvas.FontFamily - if r.defaultFontLoaded { - defaultFont = r.fontFamily + resolved, err := r.ResolveFont(fontID, false) + if err != nil { + r.renderError = err + return nil } - of, ok := r.Reader.fontCache[fontID] - if !ok { - return defaultFont + of := r.Reader.fontCache[fontID] + if len(resolved.Data) == 0 { + return nil } + if of == nil { + of = &Font{ID: fontID, FontName: "default"} + } + fontData := resolved.Data fontStyle := canvasFontStyle(of) ff := canvas.NewFontFamily(of.FontName) if of.FontFile != "" { - if fontData, err := r.Reader.FontData(fontID); err == nil { - if cidMap := getCFFCIDRuneMap(fontData); len(cidMap) > 0 { - r.fontCIDMap[fontID] = cidMap - } - if _, fixedData, mapping, _, err := FixFontDataAggressive(fontData, true, true); err == nil { - fontData = fixedData - if mapping != nil { - inv := make(map[uint16]rune) - for k, v := range mapping { - if k == packedGlyphRune(v) { - inv[v] = k - } - } - for k, v := range mapping { - if _, ok := inv[v]; !ok { - inv[v] = k - } - } - r.fontGIDMap[fontID] = inv - } - } - if err := ff.LoadFont(fontData, 0, fontStyle); err == nil { - r.fontMap[fontID] = ff - return ff - } - return nil + if cidMap := getCFFCIDRuneMap(fontData); len(cidMap) > 0 { + r.fontCIDMap[fontID] = cidMap } + if _, fixedData, mapping, _, err := FixFontDataAggressive(fontData, true, true); err == nil { + fontData = fixedData + if mapping != nil { + inv := make(map[uint16]rune) + for k, v := range mapping { + if k == packedGlyphRune(v) { + inv[v] = k + } + } + for k, v := range mapping { + if _, ok := inv[v]; !ok { + inv[v] = k + } + } + r.fontGIDMap[fontID] = inv + } + } + } + if err := ff.LoadFont(fontData, 0, fontStyle); err != nil { + r.renderError = err return nil } - for _, source := range r.fontSources(fontID, of, fontStyle) { - if loaded := r.loadFontSource(ff, source, fontStyle); loaded != nil { - r.fontMap[fontID] = loaded - r.fontSourceUsed[fontID] = source - return loaded - } + if r.Reader.fontCache[fontID] != nil { + r.fontMap[fontID] = ff } - r.fontMap[fontID] = defaultFont - return defaultFont + return ff } // canvasFontStyle 获取Canvas字体样式 @@ -213,14 +209,17 @@ func appendFontSource(sources []fontSource, seen map[fontSourceKey]bool, source } // fontSourceMatch 获取匹配的字体来源 -// 入参: fontID 字体ID, font OFD字体定义 +// 入参: fontID 字体ID, font OFD字体定义, exact 是否禁止无关回退 // 返回: fontSource 字体来源, *canvas.FontFamily 已加载的字体族,未匹配时为空 -func (r *Renderer) fontSourceMatch(fontID string, font *Font) (fontSource, *canvas.FontFamily) { - if source, ok := r.fontSourceUsed[fontID]; ok { +func (r *Renderer) fontSourceMatch(fontID string, font *Font, exact bool) (fontSource, *canvas.FontFamily) { + if source, ok := r.fontSourceUsed[fontID]; ok && (!exact || source.exact) && r.fontMap[fontID] != nil { return source, r.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.fontCache[key]; ok { if cached != nil { diff --git a/ofdgo_renderer_geom_canvas.go b/ofdgo_renderer_geom_canvas.go new file mode 100644 index 0000000..4095291 --- /dev/null +++ b/ofdgo_renderer_geom_canvas.go @@ -0,0 +1,324 @@ +// 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" + "strconv" + + "github.com/tdewolff/canvas" +) + +// Path 解析对象的标准紧缩路径并转换到页面坐标 +// 入参: object 路径对象 +// 返回: GeometryPath 页面路径, error 无效路径错误 +func (CanvasBackend) Path(object PathObject) (GeometryPath, error) { + if err := creationPath(object.AbbreviatedData); err != nil { + return nil, err + } + if _, err := creationBox(object.Boundary); err != nil { + return nil, err + } + if object.CTM != "" { + if _, err := creationNumbers(object.CTM, 6); err != nil { + return nil, err + } + } + p := (&Renderer{}).buildPath(object, 0, NewMatrix(object.CTM), false) + return *geometryFromCanvasPath(p), nil +} + +// Region 解析动作区域并保持椭圆弧及退化变换的既有处理 +// 入参: region 动作区域, matrix 页面变换 +// 返回: GeometryPath 页面路径, error 解析错误 +func (CanvasBackend) Region(region *Region, matrix Matrix) (GeometryPath, error) { + p := actionRegionPath(region, matrix) + return *geometryFromCanvasPath(p.Transform(canvas.Matrix{{1, 0, 0}, {0, -1, 0}})), nil +} + +// Bounds 计算曲线路径的精确范围 +// 入参: path 页面路径 +// 返回: Box 路径范围, error 无效路径错误 +func (CanvasBackend) Bounds(path GeometryPath) (Box, error) { + p, err := geometryToCanvasPath(&path) + if err != nil { + return Box{}, err + } + if p.Empty() { + return Box{}, nil + } + box := p.Bounds() + return Box{X: box.X0, Y: -box.Y1, W: box.W(), H: box.H()}, nil +} + +// Transform 变换路径,保持曲线与椭圆弧,不修改源路径 +// 入参: path 页面路径, matrix 页面变换 +// 返回: GeometryPath 新路径, error 无效路径错误 +func (CanvasBackend) Transform(path GeometryPath, matrix Matrix) (GeometryPath, error) { + p, err := geometryToCanvasPath(&path) + if err != nil { + return nil, err + } + m := matrix.Values() + for _, value := range m { + if !finite(value) { + return nil, fmt.Errorf("invalid geometry transform") + } + } + if geometryEqual(m[0]*m[3]-m[1]*m[2], 0) { + p = p.ReplaceArcs() + } + p = p.Transform(canvas.Matrix{{m[0], -m[2], m[4]}, {-m[1], m[3], -m[5]}}) + return *geometryFromCanvasPath(p), nil +} + +// Normalize 按填充规则整理自交区域 +// 入参: path 页面路径, evenOdd 是否采用奇偶规则 +// 返回: GeometryPath 非零填充路径, error 无效路径错误 +func (CanvasBackend) Normalize(path GeometryPath, evenOdd bool) (GeometryPath, error) { + p, err := geometryToCanvasPath(&path) + if err != nil { + return nil, err + } + rule := canvas.NonZero + if evenOdd { + rule = canvas.EvenOdd + } + p = p.Settle(rule) + if p.Empty() { + return nil, nil + } + return *geometryFromCanvasPath(p), nil +} + +// Combine 计算两个非零填充区域的布尔运算 +// 入参: left 左路径, right 右路径, operation 运算类型 +// 返回: GeometryPath 结果路径, error 运算错误 +func (CanvasBackend) Combine(left, right GeometryPath, operation GeometryOperation) (GeometryPath, error) { + a, err := geometryToCanvasPath(&left) + if err != nil { + return nil, err + } + b, err := geometryToCanvasPath(&right) + if err != nil { + return nil, err + } + var result *canvas.Path + switch operation { + case GeometryIntersect: + result = a.And(b) + case GeometryUnion: + result = a.Or(b) + case GeometrySubtract: + result = a.Not(b) + case GeometryXor: + result = a.Xor(b) + default: + return nil, fmt.Errorf("unsupported geometry operation %d", operation) + } + if result.Empty() { + return nil, nil + } + return *geometryFromCanvasPath(result), nil +} + +// Stroke 将绝对长度虚线与描边转换为填充区域 +// 入参: path 页面路径, options 描边样式 +// 返回: GeometryPath 描边区域, error 样式或路径错误 +func (CanvasBackend) Stroke(path GeometryPath, options StrokeOptions) (GeometryPath, error) { + if !finite(options.Width) || options.Width <= 0 || !finite(options.Tolerance) || options.Tolerance < 0 || !finite(options.DashOffset) || !finite(options.MiterLimit) || options.MiterLimit < 0 { + return nil, fmt.Errorf("invalid stroke width or tolerance") + } + if options.Cap != "" && options.Cap != "Butt" && options.Cap != "Round" && options.Cap != "Square" { + return nil, fmt.Errorf("invalid stroke cap %q", options.Cap) + } + if options.Join != "" && options.Join != "Miter" && options.Join != "Round" && options.Join != "Bevel" { + return nil, fmt.Errorf("invalid stroke join %q", options.Join) + } + for _, dash := range options.Dashes { + if !finite(dash) || dash < 0 { + return nil, fmt.Errorf("invalid stroke dash") + } + } + p, err := geometryToCanvasPath(&path) + if err != nil { + return nil, err + } + tolerance := options.Tolerance + if tolerance == 0 { + tolerance = canvas.Tolerance + } + style := pathStyle{lineJoin: canvas.MiterJoin, lineCap: canvas.ButtCap, miterLimit: defaultMiterLimit} + style.applyLineJoin(options.Join, options.MiterLimit) + style.lineCap = pathLineCap(options.Cap, style.lineCap) + p = p.Dash(options.DashOffset, options.Dashes...).Stroke(options.Width, style.lineCap, style.lineJoin, tolerance) + return *geometryFromCanvasPath(p), nil +} + +// Clip 解析对象裁剪并与父裁剪相交,保留未裁剪与完全裁去的区别 +// 入参: r 渲染器, clips OFD裁剪, matrix 页面变换, parent 父裁剪 +// 返回: *GeometryPath 页面裁剪, error 无效父路径错误 +func (CanvasBackend) Clip(r *Renderer, clips *Clips, matrix Matrix, parent *GeometryPath) (*GeometryPath, error) { + p, err := geometryToCanvasPath(parent) + if err != nil { + return nil, err + } + renderer := *r + renderer.renderError = nil + clip := renderer.buildClipPath(clips, 0, 0, 0, matrix) + if renderer.renderError != nil { + return nil, renderer.renderError + } + return geometryFromCanvasPath(intersectClipPath(p, clip)), nil +} + +// geometryToCanvasPath 将页面坐标路径转换为默认几何引擎的向上纵轴 +// 入参: path 独立几何路径,nil表示不裁剪 +// 返回: *canvas.Path 默认引擎路径, error 不支持的路径指令 +func geometryToCanvasPath(path *GeometryPath) (*canvas.Path, error) { + if path == nil { + return nil, nil + } + p := &canvas.Path{} + for _, segment := range *path { + switch segment.Verb { + case GeometryMove: + p.MoveTo(segment.End.X, -segment.End.Y) + case GeometryLine: + p.LineTo(segment.End.X, -segment.End.Y) + case GeometryQuad: + p.QuadTo(segment.Control1.X, -segment.Control1.Y, segment.End.X, -segment.End.Y) + case GeometryCubic: + p.CubeTo(segment.Control1.X, -segment.Control1.Y, segment.Control2.X, -segment.Control2.Y, segment.End.X, -segment.End.Y) + case GeometryArc: + p.ArcTo(segment.RadiusX, segment.RadiusY, -segment.Rotation, segment.Large, !segment.Sweep, segment.End.X, -segment.End.Y) + case GeometryClose: + p.Close() + default: + return nil, fmt.Errorf("unsupported geometry command %d", segment.Verb) + } + } + return p, nil +} + +// geometryFromCanvasPath 将默认引擎路径转换为独立的页面坐标路径,保留曲线和椭圆弧 +// 入参: path 默认引擎路径 +// 返回: *GeometryPath 独立路径,nil表示不裁剪 +func geometryFromCanvasPath(path *canvas.Path) *GeometryPath { + if path == nil { + return nil + } + result := make(GeometryPath, 0, path.Len()) + scanner := path.Scanner() + for scanner.Scan() { + end := scanner.End() + segment := GeometrySegment{End: Point{X: end.X, Y: -end.Y}} + switch scanner.Cmd() { + case canvas.MoveToCmd: + segment.Verb = GeometryMove + case canvas.LineToCmd: + segment.Verb = GeometryLine + case canvas.QuadToCmd: + control := scanner.CP1() + segment.Verb, segment.Control1 = GeometryQuad, Point{X: control.X, Y: -control.Y} + case canvas.CubeToCmd: + c1, c2 := scanner.CP1(), scanner.CP2() + segment.Verb = GeometryCubic + segment.Control1, segment.Control2 = Point{X: c1.X, Y: -c1.Y}, Point{X: c2.X, Y: -c2.Y} + case canvas.ArcToCmd: + segment.Verb = GeometryArc + segment.RadiusX, segment.RadiusY, segment.Rotation, segment.Large, segment.Sweep = scanner.Arc() + segment.Rotation, segment.Sweep = -segment.Rotation, !segment.Sweep + case canvas.CloseCmd: + segment.Verb = GeometryClose + } + result = append(result, segment) + } + return &result +} + +// actionCanvasMatrix 转换页面坐标矩阵,不翻转Y轴 +// 入参: matrix 页面变换 +// 返回: canvas.Matrix 路径变换 +func actionCanvasMatrix(matrix Matrix) canvas.Matrix { + return canvas.Matrix{{matrix.a, matrix.c, matrix.e}, {matrix.b, matrix.d, matrix.f}} +} + +// actionRegionPath 解析复杂点击区域并转换为页面坐标 +// 入参: region 动作区域, matrix 坐标变换 +// 返回: *canvas.Path 点击路径 +func actionRegionPath(region *Region, matrix Matrix) *canvas.Path { + path := &canvas.Path{} + for _, area := range region.Area { + start := parseFloats(area.Start) + if len(start) != 2 { + continue + } + part := &canvas.Path{} + part.MoveTo(start[0], start[1]) + for _, command := range area.Command { + p1, p2, p3 := parseFloats(command.Point1), parseFloats(command.Point2), parseFloats(command.Point3) + switch command.Type { + case "Move": + if len(p1) == 2 { + part.Close() + part.MoveTo(p1[0], p1[1]) + } + case "Line": + if len(p1) == 2 { + part.LineTo(p1[0], p1[1]) + } + case "QuadraticBezier": + if len(p1) == 2 && len(p2) == 2 { + part.QuadTo(p1[0], p1[1], p2[0], p2[1]) + } + case "CubicBezier": + if len(p3) == 2 { + first, second := part.Pos(), canvas.Point{X: p3[0], Y: p3[1]} + if len(p1) == 2 { + first = canvas.Point{X: p1[0], Y: p1[1]} + } + if len(p2) == 2 { + second = canvas.Point{X: p2[0], Y: p2[1]} + } + part.CubeTo(first.X, first.Y, second.X, second.Y, p3[0], p3[1]) + } + case "Arc": + size, end := parseFloats(command.EllipseSize), parseFloats(command.EndPoint) + angle, err := strconv.ParseFloat(command.RotationAngle, 64) + large, largeErr := strconv.ParseBool(command.LargeArc) + sweep, sweepErr := strconv.ParseBool(command.SweepDirection) + if len(end) == 2 && err == nil && largeErr == nil && sweepErr == nil { + rx, ry := 0.0, 0.0 + if len(size) > 0 { + rx, ry = size[0], size[0] + } + if len(size) > 1 { + ry = size[1] + } + part.ArcTo(rx, ry, angle, large, sweep, end[0], end[1]) + } + case "Close": + part.Close() + } + } + part.Close() + path = path.Append(part) + } + if canvas.Equal(matrix.a*matrix.d-matrix.b*matrix.c, 0) { + path = path.ReplaceArcs() + } + return path.Transform(actionCanvasMatrix(matrix)) +} diff --git a/ofdgo_gg.go b/ofdgo_renderer_gg.go similarity index 100% rename from ofdgo_gg.go rename to ofdgo_renderer_gg.go diff --git a/ofdgo_renderer_glyph_canvas.go b/ofdgo_renderer_glyph_canvas.go new file mode 100644 index 0000000..80bd1e6 --- /dev/null +++ b/ofdgo_renderer_glyph_canvas.go @@ -0,0 +1,111 @@ +// 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 ( + "github.com/tdewolff/canvas" + canvastext "github.com/tdewolff/canvas/text" + "github.com/tdewolff/font" +) + +// textGlyphPathCacheKey 字形路径缓存键 +type textGlyphPathCacheKey struct { + font *canvas.Font + size float64 + fauxBold float64 + fauxItalic float64 + xOffset int32 + yOffset int32 + language string + script canvastext.Script + direction canvastext.Direction + glyph textGlyph +} + +// textGlyphPathCacheValue 字形路径缓存值 +type textGlyphPathCacheValue struct { + path *canvas.Path + width float64 +} + +// textGlyphWidth 获取绘制字形宽度 +// 入参: face 字体, glyph 绘制字形 +// 返回: float64 字形宽度 +func textGlyphWidth(face *canvas.FontFace, glyph textGlyph) float64 { + if glyph.GlyphID >= 0 && glyph.GlyphID <= 0xFFFF { + return face.MmPerEm * float64(face.Font.GlyphAdvance(uint16(glyph.GlyphID))) + } + return face.TextWidth(glyph.Text) +} + +// textGlyphPath 获取绘制字形路径 +// 入参: face 字体, glyph 绘制字形 +// 返回: *canvas.Path 字形路径, float64 字形宽度 +func textGlyphPath(face *canvas.FontFace, glyph textGlyph) (*canvas.Path, float64) { + if glyph.GlyphID < 0 || glyph.GlyphID > 0xFFFF { + return face.ToPath(glyph.Text) + } + p := &canvas.Path{} + glyphID := uint16(glyph.GlyphID) + _ = face.Font.GlyphPath(p, glyphID, face.PPEM(canvas.DefaultResolution), 0, 0, face.MmPerEm, font.NoHinting) + if face.FauxBold != 0 { + d := face.FauxBold * face.Size + if face.Font.IsTrueType { + d = -d + } + origFastStroke := canvas.FastStroke + canvas.FastStroke = true + p = p.Offset(d, canvas.Tolerance) + canvas.FastStroke = origFastStroke + } + if face.FauxItalic != 0 { + p = p.Transform(canvas.Identity.Shear(face.FauxItalic, 0)) + } + return p, face.MmPerEm * float64(face.Font.GlyphAdvance(glyphID)) +} + +// cachedTextGlyphPath 获取缓存的字形路径 +// 入参: face 字体, glyph 绘制字形 +// 返回: *canvas.Path 字形路径, float64 字形宽度 +func (r *Renderer) cachedTextGlyphPath(face *canvas.FontFace, glyph textGlyph) (*canvas.Path, float64) { + key := textGlyphPathCacheKey{ + font: face.Font, + size: face.Size, + fauxBold: face.FauxBold, + fauxItalic: face.FauxItalic, + xOffset: face.XOffset, + yOffset: face.YOffset, + language: face.Language, + script: face.Script, + direction: face.Direction, + glyph: glyph, + } + if cached, ok := r.textGlyphPathCache[key]; ok { + return cached.path, cached.width + } + path, width := textGlyphPath(face, glyph) + r.textGlyphPathCache[key] = textGlyphPathCacheValue{path: path, width: width} + return path, width +} + +// textMatrix 获取文本字形变换矩阵 +// 入参: ctm OFD变换矩阵 +// 返回: canvas.Matrix 画布变换矩阵 +func textMatrix(ctm Matrix) canvas.Matrix { + return canvas.Matrix{ + {ctm.a, -ctm.c, 0}, + {-ctm.b, ctm.d, 0}, + } +} diff --git a/ofdgo_renderer_image.go b/ofdgo_renderer_image_canvas.go similarity index 51% rename from ofdgo_renderer_image.go rename to ofdgo_renderer_image_canvas.go index de85c27..98b137f 100644 --- a/ofdgo_renderer_image.go +++ b/ofdgo_renderer_image_canvas.go @@ -18,13 +18,11 @@ import ( "bufio" "fmt" "image" - "image/color" "io" "math" "github.com/tdewolff/canvas" canvasimage "github.com/tdewolff/canvas/image" - "github.com/tdewolff/canvas/renderers/rasterizer" "golang.org/x/image/draw" ) @@ -84,7 +82,7 @@ func (r *Renderer) renderImage(ctx *canvas.Context, obj ImageObject, pageH float } } clipPath := intersectClipPath(parentClip, r.buildObjectClipPath(obj.Clips, pageH, box.X, box.Y, localCTM, parentCTM, boundaryInCTM)) - img = imageWithClip(img, clipPath, m, 96) + img = r.imageWithClip(img, clipPath, m, 96) m = m.Scale(imgW/float64(img.Bounds().Dx()), imgH/float64(img.Bounds().Dy())) img, pad := imageWithTransparentEdge(img) if pad > 0 { @@ -127,58 +125,11 @@ func (r *Renderer) renderImageBorder(ctx *canvas.Context, obj ImageObject, box B }, pageH, nil, parentCTM, boundaryInCTM, clipPath) } -// imageWithMask 应用图片蒙版 -// 入参: img 图片对象, mask 蒙版图片 -// 返回: image.Image 蒙版处理后的图片对象 -func imageWithMask(img, mask image.Image) image.Image { - if img == nil || mask == nil { - return img - } - bounds := img.Bounds() - if bounds.Empty() || mask.Bounds().Empty() { - return img - } - source := imagePixelSource(img) - maskSource := imagePixelSource(mask) - maskBounds := maskSource.Bounds() - var opacity image.Image = maskSource - if bounds.Size() != maskBounds.Size() { - resized := image.NewGray(bounds) - draw.CatmullRom.Scale(resized, bounds, maskSource, maskBounds, draw.Src, nil) - opacity = resized - maskBounds = bounds - } - out := image.NewNRGBA(bounds) - if src, ok := source.(*image.NRGBA); ok { - for y := 0; y < bounds.Dy(); y++ { - offset := src.PixOffset(bounds.Min.X, bounds.Min.Y+y) - row := out.Pix[y*out.Stride : (y+1)*out.Stride] - copy(row, src.Pix[offset:offset+len(row)]) - for x := 3; x < len(row); x += 4 { - a := imageGrayAt(opacity, maskBounds.Min.X+x/4, maskBounds.Min.Y+y).Y - row[x] = uint8(int(row[x]) * int(a) / 255) - } - } - return out - } - for y := bounds.Min.Y; y < bounds.Max.Y; y++ { - for x := bounds.Min.X; x < bounds.Max.X; x++ { - mx := maskBounds.Min.X + x - bounds.Min.X - my := maskBounds.Min.Y + y - bounds.Min.Y - a := imageGrayAt(opacity, mx, my).Y - c := imageNRGBAAt(source, x, y) - c.A = uint8(int(c.A) * int(a) / 255) - out.SetNRGBA(x, y, c) - } - } - return out -} - // imageWithClip 应用图片裁剪区域 // 低分辨率图片按最低精度细化蒙版,细化后的像素上限16Mi,不降低原图分辨率 // 入参: img 图片对象, clipPath 裁剪路径, m 图片变换矩阵, dpi 最低蒙版分辨率,0保留原图精度 // 返回: image.Image 裁剪后的图片对象 -func imageWithClip(img image.Image, clipPath *canvas.Path, m canvas.Matrix, dpi float64) image.Image { +func (r *Renderer) imageWithClip(img image.Image, clipPath *canvas.Path, m canvas.Matrix, dpi float64) image.Image { if img == nil || clipPath == nil || m.Det() == 0 { return img } @@ -215,12 +166,29 @@ func imageWithClip(img image.Image, clipPath *canvas.Path, m canvas.Matrix, dpi bounds, w, h = resized.Bounds(), nw, nh } clip := clipPath.Copy().Transform(m.Inv()) - clipCanvas := canvas.New(float64(w), float64(h)) - ctx := canvas.NewContext(clipCanvas) + compiler := &canvasPageCompiler{page: &RasterPage{Width: float64(w), Height: float64(h), DPI: 25.4}} + ctx := canvas.NewContext(compiler) ctx.SetFillColor(canvas.White) ctx.SetStrokeColor(canvas.Transparent) ctx.DrawPath(0, 0, clip) - mask := rasterizer.Draw(clipCanvas, canvas.DPMM(1), canvas.DefaultColorSpace) + if compiler.err != nil { + r.renderError = compiler.err + return img + } + if r.backends.Raster == nil { + r.renderError = fmt.Errorf("image clip raster: %w", ErrBackendUnavailable) + return img + } + result, err := r.backends.Raster.Render(compiler.page) + if err != nil { + r.renderError = err + return img + } + mask, ok := result.(*image.RGBA) + if !ok { + mask = image.NewRGBA(image.Rect(0, 0, w, h)) + draw.Draw(mask, mask.Bounds(), result, result.Bounds().Min, draw.Src) + } if mask.Opaque() { return img } @@ -306,213 +274,3 @@ func (r *Renderer) readImageResource(resPath string) (image.Image, error) { img, _, err := decodeImageData(data) return img, err } - -// imageWithAlpha 合并图片透明度 -// 入参: img 图片对象, alpha 对象透明度 -// 返回: image.Image 合并后的图片对象 -func imageWithAlpha(img image.Image, alpha *int) image.Image { - if img == nil || alpha == nil { - return img - } - a := clampColor(*alpha) - if a == 255 { - return img - } - bounds := img.Bounds() - out := image.NewNRGBA(bounds) - source := imagePixelSource(img) - if src, ok := source.(*image.NRGBA); ok { - for y := 0; y < bounds.Dy(); y++ { - offset := src.PixOffset(bounds.Min.X, bounds.Min.Y+y) - row := out.Pix[y*out.Stride : (y+1)*out.Stride] - copy(row, src.Pix[offset:offset+len(row)]) - for x := 3; x < len(row); x += 4 { - row[x] = uint8(int(row[x]) * a / 255) - } - } - return out - } - for y := bounds.Min.Y; y < bounds.Max.Y; y++ { - for x := bounds.Min.X; x < bounds.Max.X; x++ { - c := imageNRGBAAt(source, x, y) - c.A = uint8(int(c.A) * a / 255) - out.SetNRGBA(x, y, c) - } - } - return out -} - -// imageWithTransparentEdge 补齐透明图片边缘颜色 -// 入参: img 图片对象 -// 返回: image.Image 补齐后的图片对象, int 补齐像素数 -func imageWithTransparentEdge(img image.Image) (image.Image, int) { - if img == nil { - return img, 0 - } - bounds := img.Bounds() - w, h := bounds.Dx(), bounds.Dy() - if w == 0 || h == 0 { - return img, 0 - } - if src, ok := img.(*canvasimage.Image); ok && src.Mimetype == "image/jpeg" && src.Mask == nil { - return img, 0 - } - source := imagePixelSource(img) - if opaque, ok := source.(interface{ Opaque() bool }); ok && opaque.Opaque() { - return img, 0 - } - hasZero, hasVisible := false, false - src, ok := source.(*image.NRGBA) - if ok { - srcBounds := src.Bounds() - hasZero = src.Pix[3] == 0 - hasVisible = !hasZero - scan: - for y := srcBounds.Min.Y; y < srcBounds.Max.Y; y++ { - offset := src.PixOffset(srcBounds.Min.X, y) + 3 - for x := 0; x < w; x++ { - if (src.Pix[offset] == 0) != hasZero { - hasZero, hasVisible = true, true - break scan - } - offset += 4 - } - } - } else { - src = image.NewNRGBA(image.Rect(0, 0, w, h)) - for y := 0; y < h; y++ { - for x := 0; x < w; x++ { - c := imageNRGBAAt(source, bounds.Min.X+x, bounds.Min.Y+y) - src.SetNRGBA(x, y, c) - if c.A == 0 { - hasZero = true - } else { - hasVisible = true - } - } - } - } - if !hasZero || !hasVisible { - return img, 0 - } - srcBounds := src.Bounds() - out := image.NewNRGBA(image.Rect(0, 0, w+2, h+2)) - draw.Draw(out, out.Bounds().Inset(1), src, srcBounds.Min, draw.Src) - for y := 0; y < h; y++ { - sy := srcBounds.Min.Y + y - offset := src.PixOffset(srcBounds.Min.X, sy) + 3 - for x := 0; x < w; x++ { - if src.Pix[offset] == 0 { - if edge, ok := transparentEdgeColor(src, srcBounds.Min.X+x, sy); ok { - out.SetNRGBA(x+1, y+1, edge) - } - } - offset += 4 - } - } - for x := 0; x < w; x++ { - out.SetNRGBA(x+1, 0, transparentPaddingColor(out.NRGBAAt(x+1, 1))) - out.SetNRGBA(x+1, h+1, transparentPaddingColor(out.NRGBAAt(x+1, h))) - } - for y := 0; y < h; y++ { - out.SetNRGBA(0, y+1, transparentPaddingColor(out.NRGBAAt(1, y+1))) - out.SetNRGBA(w+1, y+1, transparentPaddingColor(out.NRGBAAt(w, y+1))) - } - out.SetNRGBA(0, 0, transparentPaddingColor(out.NRGBAAt(1, 1))) - out.SetNRGBA(w+1, 0, transparentPaddingColor(out.NRGBAAt(w, 1))) - out.SetNRGBA(0, h+1, transparentPaddingColor(out.NRGBAAt(1, h))) - out.SetNRGBA(w+1, h+1, transparentPaddingColor(out.NRGBAAt(w, h))) - return out, 1 -} - -// imagePixelSource 获取图片像素源 -// 入参: img 图片对象 -// 返回: image.Image 图片像素源 -func imagePixelSource(img image.Image) image.Image { - if src, ok := img.(*canvasimage.Image); ok { - if decoded, err := src.Image(); err == nil { - return decoded - } - } - return img -} - -// imageGrayAt 获取图片灰度像素 -// 入参: img 图片对象, x X坐标, y Y坐标 -// 返回: color.Gray 灰度像素 -func imageGrayAt(img image.Image, x, y int) color.Gray { - if src, ok := img.(*image.Gray); ok { - return src.GrayAt(x, y) - } - if src, ok := img.(*image.Paletted); ok { - return color.GrayModel.Convert(src.At(x, y)).(color.Gray) - } - if src, ok := img.(image.RGBA64Image); ok { - r, g, b, _ := src.RGBA64At(x, y).RGBA() - return color.Gray{Y: uint8((19595*r + 38470*g + 7471*b + 1<<15) >> 24)} - } - return color.GrayModel.Convert(img.At(x, y)).(color.Gray) -} - -// imageNRGBAAt 获取图片NRGBA像素 -// 入参: img 图片对象, x X坐标, y Y坐标 -// 返回: color.NRGBA NRGBA像素 -func imageNRGBAAt(img image.Image, x, y int) color.NRGBA { - if src, ok := img.(*image.NRGBA); ok { - return src.NRGBAAt(x, y) - } - if src, ok := img.(*image.YCbCr); ok { - r, g, b, _ := src.YCbCrAt(x, y).RGBA() - return color.NRGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: 255} - } - if src, ok := img.(*image.RGBA); ok { - c := src.RGBA64At(x, y) - if c.A == 0 { - return color.NRGBA{} - } - r, g, b, a := uint32(c.R), uint32(c.G), uint32(c.B), uint32(c.A) - if a != 0xffff { - r = r * 0xffff / a - g = g * 0xffff / a - b = b * 0xffff / a - } - return color.NRGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: uint8(a >> 8)} - } - return color.NRGBAModel.Convert(img.At(x, y)).(color.NRGBA) -} - -// transparentEdgeColor 获取透明像素相邻的可见颜色 -// 入参: img 图片对象, x X坐标, y Y坐标 -// 返回: color.NRGBA 颜色, bool 是否存在 -func transparentEdgeColor(img *image.NRGBA, x, y int) (color.NRGBA, bool) { - bounds := img.Bounds() - var best color.NRGBA - for dy := -1; dy <= 1; dy++ { - for dx := -1; dx <= 1; dx++ { - if dx == 0 && dy == 0 { - continue - } - nx, ny := x+dx, y+dy - if nx < bounds.Min.X || nx >= bounds.Max.X || ny < bounds.Min.Y || ny >= bounds.Max.Y { - continue - } - c := img.NRGBAAt(nx, ny) - if c.A > best.A { - best = c - } - } - } - if best.A == 0 { - return color.NRGBA{}, false - } - best.A = 1 - return best, true -} - -// transparentPaddingColor 获取透明补齐颜色 -// 入参: c 边缘颜色 -// 返回: color.NRGBA 补齐颜色 -func transparentPaddingColor(c color.NRGBA) color.NRGBA { - c.A = 1 - return c -} diff --git a/ofdgo_renderer_layer.go b/ofdgo_renderer_layer_canvas.go similarity index 67% rename from ofdgo_renderer_layer.go rename to ofdgo_renderer_layer_canvas.go index 9a168da..8c5bece 100644 --- a/ofdgo_renderer_layer.go +++ b/ofdgo_renderer_layer_canvas.go @@ -51,40 +51,6 @@ func (r *Renderer) renderTemplate(ctx *canvas.Context, templateID string, pageH } } -// loadTemplate 加载模板页面并复用解析结果 -// 入参: templateID 模板ID -// 返回: *PageContent 模板页面 -func (r *Renderer) loadTemplate(templateID string) *PageContent { - if page := r.templatePageCache[templateID]; page != nil { - return page - } - for _, tpl := range r.Reader.doc.CommonData.TemplatePage { - if tpl.ID != templateID { - continue - } - page, err := r.Reader.PageContent(Page{BaseLoc: tpl.BaseLoc}) - if err != nil { - return nil - } - r.templatePageCache[templateID] = page - return page - } - return nil -} - -// layerOrder 获取图层类型的绘制顺序 -// 入参: kind 图层类型 -// 返回: int 绘制顺序 -func layerOrder(kind string) int { - switch kind { - case "Background": - return 0 - case "Foreground": - return 2 - } - return 1 -} - // renderLayers 按原顺序渲染指定类型的图层 // 入参: ctx 画布上下文, layers 图层列表, pageH 页面高度, order 图层类型顺序 func (r *Renderer) renderLayers(ctx *canvas.Context, layers []Layer, pageH float64, order int) { @@ -206,108 +172,3 @@ func (r *Renderer) renderObject(ctx *canvas.Context, obj *GraphicObject, pageH f r.renderCompositeGraphicUnit(ctx, obj.CompositeGraphicUnit, pageH, defaults, parentCTM, boundaryInCTM, parentClip) } } - -// mergeGraphicObjectAlpha 合并图形对象透明度 -// 入参: obj 图形对象, alpha 父级透明度 -// 返回: GraphicObject 合并后的图形对象 -func mergeGraphicObjectAlpha(obj GraphicObject, alpha *int) GraphicObject { - if alpha == nil { - return obj - } - switch obj.Type { - case "TextObject": - obj.TextObject.Alpha = mergeAlpha(obj.TextObject.Alpha, alpha) - case "PathObject": - obj.PathObject.Alpha = mergeAlpha(obj.PathObject.Alpha, alpha) - case "ImageObject": - obj.ImageObject.Alpha = mergeAlpha(obj.ImageObject.Alpha, alpha) - case "CompositeGraphicUnit", "CompositeObject": - obj.CompositeGraphicUnit.Alpha = mergeAlpha(obj.CompositeGraphicUnit.Alpha, alpha) - } - return obj -} - -// drawParamDefaults 合并绘制参数默认样式 -// 入参: id 绘制参数ID, defaults 默认绘制参数 -// 返回: *DrawParam 合并后的绘制参数 -func (r *Renderer) drawParamDefaults(id string, defaults *DrawParam) *DrawParam { - if id == "" { - return defaults - } - dp := r.getDrawParam(id, nil) - if dp == nil { - return defaults - } - if defaults == nil { - return dp - } - return mergeDrawParam(*defaults, dp) -} - -// mergeDrawParam 合并绘制参数属性 -// 入参: base 基础绘制参数, dp 覆盖绘制参数 -// 返回: *DrawParam 合并后的绘制参数 -func mergeDrawParam(base DrawParam, dp *DrawParam) *DrawParam { - if dp.LineWidth > 0 { - base.LineWidth = dp.LineWidth - } - if dp.Join != "" { - base.Join = dp.Join - } - if dp.Cap != "" { - base.Cap = dp.Cap - } - if dp.DashPattern != "" || dp.dashPatternSet { - base.DashPattern = dp.DashPattern - base.dashPatternSet = dp.dashPatternSet - } - if dp.DashOffset != nil { - base.DashOffset = dp.DashOffset - } - if dp.MiterLimit > 0 { - base.MiterLimit = dp.MiterLimit - } - if dp.FillColor != nil { - base.FillColor = dp.FillColor - } - if dp.StrokeColor != nil { - base.StrokeColor = dp.StrokeColor - } - if dp.Font != "" { - base.Font = dp.Font - } - if dp.Size > 0 { - base.Size = dp.Size - } - if dp.Weight > 0 { - base.Weight = dp.Weight - } - if dp.Italic { - base.Italic = dp.Italic - } - return &base -} - -// getDrawParam 获取绘制参数逻辑 -// 入参: id 参数ID, visited 访问记录 -// 返回: *DrawParam 绘制参数 -func (r *Renderer) getDrawParam(id string, visited map[string]bool) *DrawParam { - if visited == nil { - visited = make(map[string]bool) - } - if visited[id] { - return nil - } - visited[id] = true - if dp, ok := r.DrawParams[id]; ok { - if dp.Relative != "" { - base := r.getDrawParam(dp.Relative, visited) - if base == nil { - return dp - } - return mergeDrawParam(*base, dp) - } - return dp - } - return nil -} diff --git a/ofdgo_canvas_measure.go b/ofdgo_renderer_measure_canvas.go similarity index 77% rename from ofdgo_canvas_measure.go rename to ofdgo_renderer_measure_canvas.go index c15acb4..9356c2a 100644 --- a/ofdgo_canvas_measure.go +++ b/ofdgo_renderer_measure_canvas.go @@ -21,71 +21,6 @@ import ( "github.com/tdewolff/canvas" ) -// canvasGeometryPath 将页面坐标路径转换为默认几何引擎的向上纵轴 -// 入参: path 独立几何路径,nil表示不裁剪 -// 返回: *canvas.Path 默认引擎路径, error 不支持的路径指令 -func canvasGeometryPath(path *GeometryPath) (*canvas.Path, error) { - if path == nil { - return nil, nil - } - p := &canvas.Path{} - for _, segment := range *path { - switch segment.Verb { - case GeometryMove: - p.MoveTo(segment.End.X, -segment.End.Y) - case GeometryLine: - p.LineTo(segment.End.X, -segment.End.Y) - case GeometryQuad: - p.QuadTo(segment.Control1.X, -segment.Control1.Y, segment.End.X, -segment.End.Y) - case GeometryCubic: - p.CubeTo(segment.Control1.X, -segment.Control1.Y, segment.Control2.X, -segment.Control2.Y, segment.End.X, -segment.End.Y) - case GeometryArc: - p.ArcTo(segment.RadiusX, segment.RadiusY, -segment.Rotation, segment.Large, !segment.Sweep, segment.End.X, -segment.End.Y) - case GeometryClose: - p.Close() - default: - return nil, fmt.Errorf("unsupported geometry command %d", segment.Verb) - } - } - return p, nil -} - -// geometryCanvasPath 将默认引擎路径转换为独立的页面坐标路径,保留曲线和椭圆弧 -// 入参: path 默认引擎路径 -// 返回: *GeometryPath 独立路径,nil表示不裁剪 -func geometryCanvasPath(path *canvas.Path) *GeometryPath { - if path == nil { - return nil - } - result := make(GeometryPath, 0, path.Len()) - scanner := path.Scanner() - for scanner.Scan() { - end := scanner.End() - segment := GeometrySegment{End: Point{X: end.X, Y: -end.Y}} - switch scanner.Cmd() { - case canvas.MoveToCmd: - segment.Verb = GeometryMove - case canvas.LineToCmd: - segment.Verb = GeometryLine - case canvas.QuadToCmd: - control := scanner.CP1() - segment.Verb, segment.Control1 = GeometryQuad, Point{X: control.X, Y: -control.Y} - case canvas.CubeToCmd: - c1, c2 := scanner.CP1(), scanner.CP2() - segment.Verb = GeometryCubic - segment.Control1, segment.Control2 = Point{X: c1.X, Y: -c1.Y}, Point{X: c2.X, Y: -c2.Y} - case canvas.ArcToCmd: - segment.Verb = GeometryArc - segment.RadiusX, segment.RadiusY, segment.Rotation, segment.Large, segment.Sweep = scanner.Arc() - segment.Rotation, segment.Sweep = -segment.Rotation, !segment.Sweep - case canvas.CloseCmd: - segment.Verb = GeometryClose - } - result = append(result, segment) - } - return &result -} - // MeasureObject 使用默认解释器度量对象,不分配页面像素 // 入参: r 渲染器, object 图形对象, options 度量上下文 // 返回: ObjectMeasurement 度量结果, error 错误信息 @@ -102,12 +37,13 @@ func (CanvasBackend) MeasureObject(r *Renderer, object GraphicObject, options Me return ObjectMeasurement{}, err } } - clip, err := canvasGeometryPath(options.Clip) + clip, err := geometryToCanvasPath(options.Clip) if err != nil { return ObjectMeasurement{}, err } bounds := &boundsRenderer{collect: options.Contours} renderer := *r + renderer.renderError = nil renderer.pageText = nil renderer.textOnly = false defaults := options.Defaults @@ -127,7 +63,7 @@ func (CanvasBackend) MeasureObject(r *Renderer, object GraphicObject, options Me box = unionTextBox(box, glyph) } } - return ObjectMeasurement{Bounds: box}, nil + return ObjectMeasurement{Bounds: box}, renderer.renderError case "PathObject": obj := object.PathObject defaults = renderer.drawParamDefaults(obj.DrawParam, defaults) @@ -146,7 +82,7 @@ func (CanvasBackend) MeasureObject(r *Renderer, object GraphicObject, options Me case "CompositeObject", "CompositeGraphicUnit": renderer.renderCompositeGraphicUnit(ctx, object.CompositeGraphicUnit, 0, defaults, options.Parent, options.BoundaryInCTM, clip) } - return ObjectMeasurement{Bounds: bounds.box, Contours: bounds.contours}, nil + return ObjectMeasurement{Bounds: bounds.box, Contours: bounds.contours}, renderer.renderError } // measureImage 复用图片变换与裁剪语义度量范围,不读取像素数据 @@ -261,7 +197,7 @@ func (CanvasBackend) PageText(r *Renderer, page *PageContent) (*PageText, error) renderer.OnPageText = nil renderer.pageText = &PageText{} renderer.textOnly = true - if err := renderer.renderPageToContext(canvas.NewContext(canvas.New(box.W, box.H)), page, false); err != nil { + if err := renderer.renderCanvasPageToContext(canvas.NewContext(canvas.New(box.W, box.H)), page, false); err != nil { return nil, err } return renderer.pageText, nil diff --git a/ofdgo_canvas_output.go b/ofdgo_renderer_output_canvas.go similarity index 92% rename from ofdgo_canvas_output.go rename to ofdgo_renderer_output_canvas.go index f4a7dee..3d6aaae 100644 --- a/ofdgo_canvas_output.go +++ b/ofdgo_renderer_output_canvas.go @@ -38,7 +38,7 @@ func (CanvasBackend) RenderSVG(r *Renderer, page *PageContent, writer io.Writer, case SVGObjects: return r.renderSVGResources(page, writer, true, true) case SVGEmbedded: - c, err := r.renderPage(page) + c, err := r.renderCanvasPage(page) if err != nil { return SVGResources{}, err } @@ -58,7 +58,7 @@ func (CanvasBackend) RenderSVG(r *Renderer, page *PageContent, writer io.Writer, // 入参: r 渲染器, page 页面内容, writer 输出流 // 返回: error 错误信息 func (CanvasBackend) RenderEPS(r *Renderer, page *PageContent, writer io.Writer) error { - c, err := r.renderPage(page) + c, err := r.renderCanvasPage(page) if err != nil { return err } @@ -80,7 +80,10 @@ func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer i if len(pages) == 0 { return fmt.Errorf("no pages found") } - navigation := newPDFNavigation(r, r.Reader.doc, pages) + navigation, err := newPDFNavigation(r, r.Reader.doc, pages) + if err != nil { + return err + } buf, direct := writer.(*bytes.Buffer) if !direct { buf = &bytes.Buffer{} @@ -98,7 +101,7 @@ func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer i p.NewPage(page.Box.W, page.Box.H) } navigation.apply(p, i) - if err := r.renderPageToContext(canvas.NewContext(renderer), page.Content, true); err != nil { + if err := r.renderCanvasPageToContext(canvas.NewContext(renderer), page.Content, !r.TransparentBackground); err != nil { buf.Truncate(start) return fmt.Errorf("failed to render page %d: %w", i+1, err) } @@ -118,7 +121,7 @@ func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer i if direct { return nil } - _, err := writer.Write(data) + _, err = writer.Write(data) return err } diff --git a/ofdgo_renderer_page_canvas.go b/ofdgo_renderer_page_canvas.go new file mode 100644 index 0000000..06ba0fc --- /dev/null +++ b/ofdgo_renderer_page_canvas.go @@ -0,0 +1,95 @@ +// 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 ( + "github.com/tdewolff/canvas" +) + +// renderCanvasPage 渲染特定页面内容 +// 入参: page 页面内容 +// 返回: *canvas.Canvas 画布实例, error 错误信息 +func (r *Renderer) renderCanvasPage(page *PageContent) (*canvas.Canvas, error) { + box, err := r.GetPageBox(page) + if err != nil { + return nil, err + } + width, height := box.W, box.H + c := canvas.New(width, height) + ctx := canvas.NewContext(c) + if err := r.renderCanvasPageToContext(ctx, page, !r.TransparentBackground); err != nil { + return nil, err + } + return c, nil +} + +// renderCanvasContext 渲染页面到默认解释器上下文 +// 入参: ctx 画布上下文, page 页面内容 +// 返回: error 错误信息 +func (r *Renderer) renderCanvasContext(ctx *canvas.Context, page *PageContent) error { + renderer := *r + renderer.decodeImages = true + return renderer.renderCanvasPageToContext(ctx, page, !r.TransparentBackground) +} + +// renderCanvasPageToContext 渲染页面到指定上下文 +// 入参: ctx 画布上下文, page 页面内容, drawBackground 是否绘制页面背景 +// 返回: error 错误信息 +func (r *Renderer) renderCanvasPageToContext(ctx *canvas.Context, page *PageContent, drawBackground bool) error { + r.renderError = nil + box, err := r.GetPageBox(page) + if err != nil { + return err + } + if r.OnPageText != nil { + renderer := *r + renderer.pageText = &PageText{} + r = &renderer + } + pageH := box.H + if drawBackground { + ctx.SetFillColor(canvas.White) + ctx.DrawPath(0, 0, canvas.Rectangle(box.W, box.H)) + } + for order := range 3 { + if r.Reader.doc != nil { + for _, tplRef := range page.Template { + kind := tplRef.ZOrder + if kind == "" { + kind = "Background" + } + if layerOrder(kind) == order { + r.renderTemplate(ctx, tplRef.TemplateID, pageH) + } + } + } + r.renderLayers(ctx, page.Content.Layer, pageH, order) + } + if r.RenderAnnotations { + r.renderAnnotations(ctx, page.ID, pageH) + } + if stamps, ok := r.Reader.Stamps[page.ID]; ok { + for _, stamp := range stamps { + r.renderStamp(ctx, stamp, pageH) + } + } + if r.renderError != nil { + return r.renderError + } + if r.OnPageText != nil { + r.OnPageText(page, r.pageText) + } + return nil +} diff --git a/ofdgo_renderer_paint_canvas.go b/ofdgo_renderer_paint_canvas.go new file mode 100644 index 0000000..76fa3cb --- /dev/null +++ b/ofdgo_renderer_paint_canvas.go @@ -0,0 +1,129 @@ +// 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 ( + "github.com/tdewolff/canvas" + "math" +) + +// parseFillPaint 解析填充画刷 +// 入参: fillColor 填充颜色节点, x X坐标, y Y坐标, pageH 页面高度 +// 返回: any 填充画刷 +func (r *Renderer) parseFillPaint(fillColor *FillColor, x, y, pageH float64) any { + if fillColor == nil { + return nil + } + if fillColor.Pattern != nil { + return nil + } + if gradient := r.parseAxialShdGradient(fillColor.AxialShd, fillColor.Alpha, x, y, pageH); gradient != nil { + return newShdPaint(gradient, fillColor.AxialShd.Extend, fillColor.AxialShd.MapType, fillColor.AxialShd.MapUnit) + } + if paint := r.parseRadialShdPaint(fillColor.RadialShd, fillColor.Alpha, x, y, pageH); paint != nil { + return paint + } + return r.parseFillColor(fillColor) +} + +// parseStrokePaint 解析勾边画刷 +// 入参: strokeColor 勾边颜色节点, x X坐标, y Y坐标, pageH 页面高度 +// 返回: any 勾边画刷 +func (r *Renderer) parseStrokePaint(strokeColor *StrokeColor, x, y, pageH float64) any { + return r.parseFillPaint((*FillColor)(strokeColor), x, y, pageH) +} + +// parseShdSegments 将通用渐变分段转换为Canvas画刷 +// 入参: segments 渐变分段, alpha 透明度 +// 返回: canvas.Grad 渐变分段 +func (r *Renderer) parseShdSegments(segments []ShdSegment, alpha *int) canvas.Grad { + var gradient canvas.Grad + for _, stop := range r.GradientStops(segments, alpha) { + gradient.Add(stop.Offset, stop.Color) + } + return gradient +} + +// parseAxialShdGradient 解析轴向渐变 +// 入参: axialShd 轴向渐变节点, alpha 透明度, x X坐标, y Y坐标, pageH 页面高度 +// 返回: canvas.Gradient 渐变对象 +func (r *Renderer) parseAxialShdGradient(axialShd *AxialShd, alpha *int, x, y, pageH float64) canvas.Gradient { + if axialShd == nil { + return nil + } + start := parseFloats(axialShd.StartPoint) + end := parseFloats(axialShd.EndPoint) + if len(start) < 2 || len(end) < 2 { + return nil + } + gradient := r.parseShdSegments(axialShd.Segment, alpha) + if gradient == nil { + return nil + } + startPoint := canvas.Point{X: x + start[0], Y: pageH - (y + start[1])} + endPoint := canvas.Point{X: x + end[0], Y: pageH - (y + end[1])} + if startPoint.Equals(endPoint) { + return nil + } + return gradient.ToLinear(startPoint, endPoint) +} + +// axialShdClip 获取轴向渐变的延伸裁剪区域 +// 入参: gradient 轴向渐变, extend 延伸方向, bounds 可见区域 +// 返回: *canvas.Path 裁剪区域 +func axialShdClip(gradient *canvas.LinearGradient, extend int, bounds canvas.Rect) *canvas.Path { + if extend == 3 { + return nil + } + d := gradient.End.Sub(gradient.Start) + axis := canvas.Matrix{{d.X, -d.Y, gradient.Start.X}, {d.Y, d.X, gradient.Start.Y}} + area := bounds.Transform(axis.Inv()) + if extend&1 == 0 { + area.X0 = math.Max(area.X0, 0) + } + if extend&2 == 0 { + area.X1 = math.Min(area.X1, 1) + } + if area.X1 <= area.X0 { + return &canvas.Path{} + } + return area.ToPath().Transform(axis) +} + +// parseRadialShdPaint 解析径向渐变画刷 +// 入参: radialShd 径向渐变节点, alpha 透明度, x X坐标, y Y坐标, pageH 页面高度 +// 返回: *shdPaint 渐变画刷 +func (r *Renderer) parseRadialShdPaint(radialShd *RadialShd, alpha *int, x, y, pageH float64) *shdPaint { + if radialShd == nil || radialShd.EndRadius <= 0 { + return nil + } + start := parseFloats(radialShd.StartPoint) + end := parseFloats(radialShd.EndPoint) + if len(start) < 2 || len(end) < 2 { + return nil + } + gradient := r.parseShdSegments(radialShd.Segment, alpha) + if gradient == nil { + return nil + } + startPoint := canvas.Point{X: x + start[0], Y: pageH - (y + start[1])} + endPoint := canvas.Point{X: x + end[0], Y: pageH - (y + end[1])} + paint := newShdPaint(gradient.ToRadial(startPoint, radialShd.StartRadius, endPoint, radialShd.EndRadius), radialShd.Extend, radialShd.MapType, radialShd.MapUnit) + if e := radialShd.Eccentricity; 0 < e && e < 1 { + paint.view = canvas.Identity.Translate(startPoint.X, startPoint.Y).Rotate(-radialShd.Angle).Scale(1, math.Sqrt(1-e*e)).Translate(-startPoint.X, -startPoint.Y) + paint.gradient = gradient.ToRadial(startPoint, radialShd.StartRadius, paint.view.Inv().Dot(endPoint), radialShd.EndRadius) + } + return paint +} diff --git a/ofdgo_renderer_path.go b/ofdgo_renderer_path_canvas.go similarity index 72% rename from ofdgo_renderer_path.go rename to ofdgo_renderer_path_canvas.go index f8b43c1..87e79ba 100644 --- a/ofdgo_renderer_path.go +++ b/ofdgo_renderer_path_canvas.go @@ -23,9 +23,6 @@ import ( "github.com/tdewolff/canvas" ) -const defaultPathLineWidth = 0.353 -const defaultMiterLimit = 3.528 - // pathStyle 路径绘制样式 type pathStyle struct { fillColor color.Color @@ -311,9 +308,9 @@ func (r *Renderer) renderPattern(ctx *canvas.Context, pattern *patternPaint, pag return } if r.pageText != nil { - renderer := *r - renderer.pageText = nil - r = &renderer + text := r.pageText + r.pageText = nil + defer func() { r.pageText = text }() } if clip == nil || clip.Empty() || len(pattern.CellContent.Objects) == 0 { return @@ -456,186 +453,6 @@ func (r *Renderer) buildPath(obj PathObject, pageH float64, ctm Matrix, boundary return p.Transform(canvas.Matrix{{ctm.a, ctm.c, ctm.e}, {-ctm.b, -ctm.d, pageH - ctm.f}}) } -// buildObjectClipPath 构建对象裁剪路径并应用父级变换 -// 入参: 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 !boundaryInCTM && parentCTM != nil { - objectCTM = parentCTM.Multiply(objectCTM) - } - p := r.buildClipPath(clips, pageH, bx, by, objectCTM) - if p != nil && boundaryInCTM && parentCTM != nil { - p = p.Transform(canvas.Matrix{ - {parentCTM.a, -parentCTM.c, parentCTM.c*pageH + parentCTM.e}, - {-parentCTM.b, parentCTM.d, pageH*(1-parentCTM.d) - parentCTM.f}, - }) - } - return p -} - -// buildClipPath 构建裁剪路径 -// 入参: clips 裁剪对象, pageH 页面高度, bx 边界X坐标, by 边界Y坐标, objectCTM 对象CTM -// 返回: *canvas.Path 路径对象 -func (r *Renderer) buildClipPath(clips *Clips, pageH float64, bx, by float64, objectCTM Matrix) *canvas.Path { - if clips == nil { - return nil - } - var p *canvas.Path - for _, clip := range clips.Clip { - renderer := &clipRenderer{} - for _, area := range clip.Area { - areaCTM := NewMatrix(area.CTM) - if clips.TransFlag == nil || *clips.TransFlag { - areaCTM = objectCTM.Multiply(areaCTM) - } - for _, pathObj := range area.Path { - ctm := areaCTM.Multiply(NewMatrix(pathObj.CTM)) - cp := r.buildPath(pathObj, pageH, ctm, true) - cp.Translate(bx, -by) - cp.Close() - if pathObj.Rule == "Even-Odd" { - cp = cp.Settle(canvas.EvenOdd) - } - renderer.add(cp) - } - for _, textObj := range area.Text { - box, _ := ParseBox(textObj.Boundary) - ctm := NewMatrix(textObj.CTM) - m := areaCTM.Multiply(TranslationMatrix(box.X, box.Y)).Multiply(ctm) - ctx := canvas.NewContext(renderer) - ctx.SetView(canvas.Matrix{{m.a, -m.c, bx + m.e}, {-m.b, m.d, pageH - by - m.f}}) - renderer.clip = r.buildObjectClipPath(textObj.Clips, pageH, box.X, box.Y, ctm, &areaCTM, true) - if renderer.clip != nil { - renderer.clip = renderer.clip.Translate(bx, -by) - } - textObj.Boundary, textObj.CTM = "", "" - textObj.Clips = nil - textObj.Alpha = nil - textObj.FillColor = &FillColor{Value: "0 0 0"} - textObj.StrokeColor = &StrokeColor{Value: "0 0 0"} - textRenderer := *r - textRenderer.pageText = nil - textRenderer.textOnly = false - textRenderer.renderText(ctx, textObj, 0, r.drawParamDefaults(area.DrawParam, nil), nil, false, nil) - } - } - if renderer.path == nil { - renderer.path = &canvas.Path{} - } - p = intersectClipPath(p, renderer.path) - } - return p -} - -// intersectClipPath 求裁剪路径交集 -// 入参: parent 父级裁剪路径, current 当前裁剪路径 -// 返回: *canvas.Path 相交后的裁剪路径 -func intersectClipPath(parent, current *canvas.Path) *canvas.Path { - if parent == nil { - return current - } - if current == nil { - return parent - } - parentRect, parentOK := rectangularPath(parent) - currentRect, currentOK := rectangularPath(current) - if parentOK && currentOK { - return parentRect.And(currentRect).ToPath() - } - if parent.Empty() || current.Empty() { - return &canvas.Path{} - } - if parentOK && parentRect.Contains(current.FastBounds()) { - return current - } - if currentOK && currentRect.Contains(parent.FastBounds()) { - return parent - } - return parent.And(current) -} - -// unionClipPath 合并裁剪区域 -// 入参: left 左侧裁剪路径, right 右侧裁剪路径 -// 返回: *canvas.Path 合并后的裁剪路径 -func unionClipPath(left, right *canvas.Path) *canvas.Path { - leftRect, leftOK := rectangularPath(left) - rightRect, rightOK := rectangularPath(right) - if leftOK && rightOK { - bounds := leftRect.Add(rightRect) - area := leftRect.Area() + rightRect.Area() - leftRect.And(rightRect).Area() - if canvas.Equal(area, bounds.Area()) { - return bounds.ToPath() - } - } - return left.Or(right) -} - -// applyClipPath 应用裁剪路径 -// 入参: path 绘制路径, clip 裁剪路径 -// 返回: *canvas.Path 裁剪后的绘制路径 -func applyClipPath(path, clip *canvas.Path) *canvas.Path { - if path == nil || clip == nil { - return path - } - if path.Empty() || clip.Empty() { - return &canvas.Path{} - } - if rect, ok := rectangularPath(clip); ok { - bounds := path.FastBounds() - if rect.Contains(bounds) { - return path - } - if !rect.Overlaps(bounds) { - return &canvas.Path{} - } - if pathRect, ok := rectangularPath(path); ok { - return pathRect.And(rect).ToPath() - } - } - return path.And(clip) -} - -// rectangularPath 获取矩形路径区域 -// 入参: path 路径对象 -// 返回: canvas.Rect 矩形区域, bool 是否为矩形 -func rectangularPath(path *canvas.Path) (canvas.Rect, bool) { - if path == nil || path.HasSubpaths() || !path.Closed() { - return canvas.Rect{}, false - } - data := path.Data() - for i := 0; i < len(data); i += 4 { - if i+4 > len(data) || (data[i] != canvas.MoveToCmd && data[i] != canvas.LineToCmd && data[i] != canvas.CloseCmd) { - return canvas.Rect{}, false - } - } - points := path.Coords() - if len(points) != 5 || !points[0].Equals(points[4]) { - return canvas.Rect{}, false - } - rect := path.FastBounds() - corners := 0 - for _, point := range points[:4] { - corner := 0 - switch { - case canvas.Equal(point.X, rect.X0) && canvas.Equal(point.Y, rect.Y0): - corner = 1 - case canvas.Equal(point.X, rect.X1) && canvas.Equal(point.Y, rect.Y0): - corner = 2 - case canvas.Equal(point.X, rect.X1) && canvas.Equal(point.Y, rect.Y1): - corner = 4 - case canvas.Equal(point.X, rect.X0) && canvas.Equal(point.Y, rect.Y1): - corner = 8 - default: - return canvas.Rect{}, false - } - if corners&corner != 0 { - return canvas.Rect{}, false - } - corners |= corner - } - return rect, corners == 15 -} - // buildTinyFillRectPath 构建微小填充矩形路径 // 入参: obj 路径对象, pageH 页面高度, ctm 变换矩阵, bx 边界X坐标, by 边界Y坐标 // 返回: *canvas.Path 路径对象 diff --git a/ofdgo_canvas_pdf.go b/ofdgo_renderer_pdf_canvas.go similarity index 90% rename from ofdgo_canvas_pdf.go rename to ofdgo_renderer_pdf_canvas.go index 0a86c4c..7175286 100644 --- a/ofdgo_canvas_pdf.go +++ b/ofdgo_renderer_pdf_canvas.go @@ -71,8 +71,8 @@ type pdfOutline struct { // newPDFNavigation 创建PDF导航信息 // 入参: renderer 渲染器, doc 文档结构, pages 页面数据 -// 返回: *pdfNavigation PDF导航信息 -func newPDFNavigation(renderer *Renderer, doc *Document, pages []RenderDocumentPage) *pdfNavigation { +// 返回: *pdfNavigation PDF导航信息, error 几何错误 +func newPDFNavigation(renderer *Renderer, doc *Document, pages []RenderDocumentPage) (*pdfNavigation, error) { navigation := &pdfNavigation{ Anchor: make(map[int][]pdfAnchor), Link: make(map[int][]pdfLink), @@ -98,7 +98,10 @@ func newPDFNavigation(renderer *Renderer, doc *Document, pages []RenderDocumentP if action.Event != "CLICK" { continue } - box, _ := actionLinkRegion(source, action) + box, _, err := renderer.actionLinkRegion(source, action) + if err != nil { + return nil, err + } if box.W <= 0 || box.H <= 0 { continue } @@ -110,7 +113,7 @@ func newPDFNavigation(renderer *Renderer, doc *Document, pages []RenderDocumentP if doc != nil { navigation.addOutlines(doc.Outlines.OutlineElem, 0, bookmarks, pageIndex, pages) } - return navigation + return navigation, nil } // addAction 添加PDF动作 @@ -173,25 +176,6 @@ func (n *pdfNavigation) apply(renderer *pdf.PDF, page int) { } } -// outlineDest 获取大纲跳转目标 -// 入参: outline 大纲节点, bookmarks 书签 -// 返回: *Dest 跳转目标 -func outlineDest(outline OutlineElem, bookmarks map[string]Dest) *Dest { - for _, action := range outline.Actions { - if action.Goto != nil { - if dest := gotoDest(action.Goto, bookmarks); dest != nil { - return dest - } - } - } - for _, child := range outline.OutlineElem { - if dest := outlineDest(child, bookmarks); dest != nil { - return dest - } - } - return nil -} - // pdfSourceRect 转换PDF动作区域 // 入参: box OFD区域, pageH 页面高度 // 返回: canvas.Rect PDF区域 diff --git a/ofdgo_shading.go b/ofdgo_renderer_shading_canvas.go similarity index 100% rename from ofdgo_shading.go rename to ofdgo_renderer_shading_canvas.go diff --git a/ofdgo_renderer_stamp.go b/ofdgo_renderer_stamp_canvas.go similarity index 61% rename from ofdgo_renderer_stamp.go rename to ofdgo_renderer_stamp_canvas.go index 7f13dd6..9fcd3f0 100644 --- a/ofdgo_renderer_stamp.go +++ b/ofdgo_renderer_stamp_canvas.go @@ -17,12 +17,8 @@ package ofdgo import ( "bytes" "image" - "image/color" - "image/draw" - "math" "github.com/tdewolff/canvas" - "github.com/tdewolff/canvas/renderers/rasterizer" ) // renderStamp 渲染印章 @@ -56,8 +52,12 @@ func (r *Renderer) renderStamp(ctx *canvas.Context, s Stamp, pageH float64) { ctx.Push() ctx.Translate(s.Box.X, pageH-(s.Box.Y+s.Box.H)) ctx.Scale(s.Box.W/sealBox.W, s.Box.H/sealBox.H) - renderer.renderPageToContext(ctx, content, false) + err = renderer.renderCanvasPageToContext(ctx, content, false) ctx.Pop() + if err != nil { + r.renderError = err + return + } } return } @@ -90,20 +90,18 @@ func (r *Renderer) renderOFDStampImage(data []byte) image.Image { } renderer := r.childRenderer(reader) renderer.decodeImages = true + renderer.TransparentBackground = true for _, pageRef := range doc.Pages.Page { content, err := reader.PageContent(pageRef) if err != nil { continue } - sealBox, err := renderer.GetPageBox(content) + img, err := renderer.RenderToImage(content) if err != nil { - continue + r.renderError = err + return nil } - c := canvas.New(sealBox.W, sealBox.H) - if err := renderer.renderPageToContext(canvas.NewContext(c), content, false); err != nil { - continue - } - return rasterizer.Draw(c, canvas.DPMM(r.DPI/25.4), canvas.DefaultColorSpace) + return img } return nil } @@ -125,51 +123,3 @@ func (r *Renderer) renderStampImage(ctx *canvas.Context, img image.Image, s Stam ctx.DrawImage(0, 0, img, canvas.DPMM(1.0)) ctx.Pop() } - -// clipStampImage 裁剪印章图像 -// 入参: img 印章图像, box 印章区域, clip 裁剪区域 -// 返回: image.Image 裁剪后的印章图像 -func clipStampImage(img image.Image, box, clip Box) image.Image { - bounds := img.Bounds() - x0 := int(math.Floor(clip.X / box.W * float64(bounds.Dx()))) - y0 := int(math.Floor(clip.Y / box.H * float64(bounds.Dy()))) - x1 := int(math.Ceil((clip.X + clip.W) / box.W * float64(bounds.Dx()))) - y1 := int(math.Ceil((clip.Y + clip.H) / box.H * float64(bounds.Dy()))) - out := image.NewNRGBA(image.Rect(0, 0, x1-x0, y1-y0)) - draw.Draw(out, out.Bounds(), img, image.Pt(bounds.Min.X+x0, bounds.Min.Y+y0), draw.Src) - return out -} - -// stampImageWithTransparentWhite 处理印章图片白色底色 -// 入参: img 印章图片对象 -// 返回: image.Image 处理后的印章图片对象 -func stampImageWithTransparentWhite(img image.Image) image.Image { - if opaque, ok := img.(interface{ Opaque() bool }); ok && !opaque.Opaque() { - return img - } - bounds := img.Bounds() - out := image.NewNRGBA(bounds) - hasAlpha := false - for y := bounds.Min.Y; y < bounds.Max.Y; y++ { - for x := bounds.Min.X; x < bounds.Max.X; x++ { - c := color.NRGBAModel.Convert(img.At(x, y)).(color.NRGBA) - if c.A < 255 { - hasAlpha = true - } - out.SetNRGBA(x, y, c) - } - } - if hasAlpha { - return img - } - for y := bounds.Min.Y; y < bounds.Max.Y; y++ { - for x := bounds.Min.X; x < bounds.Max.X; x++ { - c := out.NRGBAAt(x, y) - if c.R >= 250 && c.G >= 250 && c.B >= 250 { - c.A = 0 - out.SetNRGBA(x, y, c) - } - } - } - return out -} diff --git a/ofdgo_canvas_svg.go b/ofdgo_renderer_svg_canvas.go similarity index 97% rename from ofdgo_canvas_svg.go rename to ofdgo_renderer_svg_canvas.go index e2136dd..e04dab2 100644 --- a/ofdgo_canvas_svg.go +++ b/ofdgo_renderer_svg_canvas.go @@ -80,11 +80,11 @@ func (r *Renderer) renderSVGResources(page *PageContent, writer io.Writer, image s.objects[object] = editorObjectID(*object) } } - if err := r.renderPageToContext(canvas.NewContext(s), page, true); err != nil { + if err := r.renderCanvasPageToContext(canvas.NewContext(s), page, !r.TransparentBackground); err != nil { return SVGResources{}, err } } else { - c, err := r.renderPage(page) + c, err := r.renderCanvasPage(page) if err != nil { return SVGResources{}, err } @@ -188,7 +188,7 @@ func (s *svgResourceRenderer) RenderText(text *canvas.Text, m canvas.Matrix) { font := text.MostCommonFontFace().Font resource, ok := s.renderer.svgFontCache[font] if !ok { - data := font.SFNT.Write() + data := fontSFNTData(font.SFNT) resource = SVGFont{ Name: fmt.Sprintf("ofdgo-%x-%d", sha256.Sum256(data), font.Style()), Weight: font.Style().CSS(), diff --git a/ofdgo_renderer_text.go b/ofdgo_renderer_text_canvas.go similarity index 100% rename from ofdgo_renderer_text.go rename to ofdgo_renderer_text_canvas.go diff --git a/ofdgo_shape.go b/ofdgo_shape.go index 4d1802f..0e5e0e6 100644 --- a/ofdgo_shape.go +++ b/ofdgo_shape.go @@ -19,28 +19,14 @@ import ( "math" "strconv" "strings" - - "github.com/tdewolff/canvas" ) -// Outline 获取页面坐标系中的SVG路径轮廓,不包含描边宽度和填充 +// Outline 使用默认几何后端获取SVG轮廓,自定义后端使用Renderer.PathOutline // 入参: p 路径对象 // 返回: string SVG路径数据, error 错误信息 func (p PathObject) Outline() (string, error) { - if err := creationPath(p.AbbreviatedData); err != nil { - return "", err - } - if _, err := creationBox(p.Boundary); err != nil { - return "", err - } - if p.CTM != "" { - if _, err := creationNumbers(p.CTM, 6); err != nil { - return "", err - } - } - r := &Renderer{} - path := r.buildPath(p, 0, NewMatrix(p.CTM), false) - return path.Transform(canvas.Matrix{{1, 0, 0}, {0, -1, 0}}).ToSVG(), nil + r := &Renderer{backends: defaultRenderBackends()} + return r.PathOutline(p) } // ShapeKind 基本图形类型 diff --git a/ofdgo_text.go b/ofdgo_text.go index 272423c..22a1246 100644 --- a/ofdgo_text.go +++ b/ofdgo_text.go @@ -19,10 +19,6 @@ import ( "strconv" "strings" "unicode/utf16" - - "github.com/tdewolff/canvas" - canvastext "github.com/tdewolff/canvas/text" - "github.com/tdewolff/font" ) // Text 获取定位后的文字,横向换行以换行符分隔,字形索引以占位字符表示 @@ -77,26 +73,6 @@ type textGlyph struct { GlyphID int } -// textGlyphPathCacheKey 字形路径缓存键 -type textGlyphPathCacheKey struct { - font *canvas.Font - size float64 - fauxBold float64 - fauxItalic float64 - xOffset int32 - yOffset int32 - language string - script canvastext.Script - direction canvastext.Direction - glyph textGlyph -} - -// textGlyphPathCacheValue 字形路径缓存值 -type textGlyphPathCacheValue struct { - path *canvas.Path - width float64 -} - // textGlyphTransform 字符到字形变换 type textGlyphTransform struct { CodeCount int @@ -193,66 +169,6 @@ func (r *Renderer) textGlyphFromID(fontID string, glyphID int) textGlyph { return textGlyph{GlyphID: glyphID} } -// textGlyphWidth 获取绘制字形宽度 -// 入参: face 字体, glyph 绘制字形 -// 返回: float64 字形宽度 -func textGlyphWidth(face *canvas.FontFace, glyph textGlyph) float64 { - if glyph.GlyphID >= 0 && glyph.GlyphID <= 0xFFFF { - return face.MmPerEm * float64(face.Font.GlyphAdvance(uint16(glyph.GlyphID))) - } - return face.TextWidth(glyph.Text) -} - -// textGlyphPath 获取绘制字形路径 -// 入参: face 字体, glyph 绘制字形 -// 返回: *canvas.Path 字形路径, float64 字形宽度 -func textGlyphPath(face *canvas.FontFace, glyph textGlyph) (*canvas.Path, float64) { - if glyph.GlyphID < 0 || glyph.GlyphID > 0xFFFF { - return face.ToPath(glyph.Text) - } - p := &canvas.Path{} - glyphID := uint16(glyph.GlyphID) - _ = face.Font.GlyphPath(p, glyphID, face.PPEM(canvas.DefaultResolution), 0, 0, face.MmPerEm, font.NoHinting) - if face.FauxBold != 0 { - d := face.FauxBold * face.Size - if face.Font.IsTrueType { - d = -d - } - origFastStroke := canvas.FastStroke - canvas.FastStroke = true - p = p.Offset(d, canvas.Tolerance) - canvas.FastStroke = origFastStroke - } - if face.FauxItalic != 0 { - p = p.Transform(canvas.Identity.Shear(face.FauxItalic, 0)) - } - return p, face.MmPerEm * float64(face.Font.GlyphAdvance(glyphID)) -} - -// cachedTextGlyphPath 获取缓存的字形路径 -// 入参: face 字体, glyph 绘制字形 -// 返回: *canvas.Path 字形路径, float64 字形宽度 -func (r *Renderer) cachedTextGlyphPath(face *canvas.FontFace, glyph textGlyph) (*canvas.Path, float64) { - key := textGlyphPathCacheKey{ - font: face.Font, - size: face.Size, - fauxBold: face.FauxBold, - fauxItalic: face.FauxItalic, - xOffset: face.XOffset, - yOffset: face.YOffset, - language: face.Language, - script: face.Script, - direction: face.Direction, - glyph: glyph, - } - if cached, ok := r.textGlyphPathCache[key]; ok { - return cached.path, cached.width - } - path, width := textGlyphPath(face, glyph) - r.textGlyphPathCache[key] = textGlyphPathCacheValue{path: path, width: width} - return path, width -} - // hasTextMatrix 判断文本是否需要应用字形变换 // 入参: ctm 变换矩阵 // 返回: bool 是否需要变换 @@ -261,16 +177,6 @@ func hasTextMatrix(ctm Matrix) bool { return math.Abs(ctm.a-1) > eps || math.Abs(ctm.b) > eps || math.Abs(ctm.c) > eps || math.Abs(ctm.d-1) > eps } -// textMatrix 获取文本字形变换矩阵 -// 入参: ctm OFD变换矩阵 -// 返回: canvas.Matrix 画布变换矩阵 -func textMatrix(ctm Matrix) canvas.Matrix { - return canvas.Matrix{ - {ctm.a, -ctm.c, 0}, - {-ctm.b, ctm.d, 0}, - } -} - // fontGlyphRune 获取字形ID对应的包装字体字符 // 入参: fontID 字体ID, glyphID 字形ID或CID // 返回: rune 包装字体字符, bool 是否存在 diff --git a/ofdgo_text_layout.go b/ofdgo_text_layout.go new file mode 100644 index 0000000..603407b --- /dev/null +++ b/ofdgo_text_layout.go @@ -0,0 +1,122 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "fmt" + "math" + "slices" + "strings" + "unicode/utf8" +) + +// LayoutText 按自有字体度量重排横向文字,保留绘制属性,不进行复杂文字塑形 +// 不修改文档,通过AddObject或UpdateObject提交;选项仅供当前编辑过程使用,保存为标准文字定位 +// 入参: obj 文字对象, value 原文, options 段落排版选项, metrics 字体度量 +// 返回: error 错误信息 +func LayoutText(obj *TextObject, value string, options TextLayout, metrics FontMetrics) error { + if metrics.UnitsPerEm() == 0 { + return fmt.Errorf("font units per em must be positive") + } + if obj.ReadDirection != 0 || obj.CharDirection != 0 { + return fmt.Errorf("automatic text layout requires horizontal text") + } + if !finite(obj.Size) || obj.Size <= 0 || !finite(obj.HScale) || obj.HScale < 0 { + return fmt.Errorf("invalid text dimensions") + } + value = strings.ReplaceAll(strings.ReplaceAll(value, "\r\n", "\n"), "\r", "\n") + if !utf8.ValidString(value) || strings.Contains(value, "\t") || strings.Trim(value, "\n") == "" { + return fmt.Errorf("text must contain UTF-8 characters without tabs") + } + lineHeight := options.LineHeight + if !finite(lineHeight) || lineHeight < 0 { + return fmt.Errorf("line height must be finite and nonnegative") + } + if !finite(options.LetterSpacing) { + return fmt.Errorf("letter spacing must be finite") + } + if !finite(options.LeftIndent) || !finite(options.RightIndent) || !finite(options.FirstLineIndent) { + return fmt.Errorf("paragraph indents must be finite") + } + if !slices.Contains([]string{"", "left", "center", "right", "justify"}, options.Align) { + return fmt.Errorf("invalid text alignment %q", options.Align) + } + var width float64 + if options.Wrap || options.Align != "" && options.Align != "left" || options.LeftIndent != 0 || options.RightIndent != 0 || options.FirstLineIndent != 0 { + box, err := obj.TextFrame() + if err != nil { + return err + } + width = box.W - options.LeftIndent - options.RightIndent + if !finite(width) || width <= 0 || !finite(width-options.FirstLineIndent) || width-options.FirstLineIndent <= 0 { + return fmt.Errorf("paragraph indents leave no text width") + } + } + hScale := obj.HScale + if hScale == 0 { + hScale = 1 + } + ascender, descender, gap := metrics.VerticalMetrics() + unit := obj.Size / float64(metrics.UnitsPerEm()) + if lineHeight == 0 { + lineHeight = math.Max(obj.Size, float64(int(ascender)+int(descender)+int(gap))*unit) + } + var codes []TextCode + var missing []rune + for _, paragraph := range strings.Split(value, "\n") { + runes := []rune(paragraph) + advances := make([]float64, len(runes)) + for i, char := range runes { + glyph := metrics.GlyphIndex(char) + if glyph == 0 { + missing = append(missing, char) + continue + } + advances[i] = float64(metrics.GlyphAdvance(glyph)) * unit * hScale + } + if len(missing) != 0 { + continue + } + spaceTextAdvances(runes, advances, options.LetterSpacing) + for _, advance := range advances { + if !finite(advance) { + return fmt.Errorf("text advance exceeds finite range") + } + } + lines := breakTextLines(runes, advances, width, options.Wrap, options.LetterSpacing, options.FirstLineIndent) + for i, line := range lines { + indent := 0.0 + if i == 0 { + indent = options.FirstLineIndent + } + x, deltas := alignTextLine(runes[line[0]:line[1]], advances[line[0]:line[1]], width-indent, options.Align, i+1 < len(lines), options.LetterSpacing) + x += options.LeftIndent + indent + y := float64(ascender)*unit + float64(len(codes))*lineHeight + if !finite(x) || !finite(y) { + return fmt.Errorf("text position exceeds finite range") + } + codes = append(codes, TextCode{X: ofdNumber(x), Y: ofdNumber(y), DeltaX: deltas, Value: escapeOFDText(string(runes[line[0]:line[1]]))}) + } + } + if err := missingGlyphError(obj.Font, missing); err != nil { + return err + } + obj.TextCode = codes + obj.layout = nil + if options != (TextLayout{}) { + obj.layout = &textLayout{value: value, options: options} + } + return nil +}