feat(后端架构): 完善后端解耦
build.yaml / build (push) Successful in 39s

This commit is contained in:
xiaoqidun committed 2026-09-23 16:12:57 +08:00
1 parent 73472c5d4b
commit 77ffbe6a72
56 files changed
+2397 -1612

No files matched your search

+12 -9
View File
@@ -43,13 +43,14 @@
<button id="continuousButton" class="button" type="button" data-short="连" title="连续显示" aria-label="连续显示" aria-pressed="false" disabled>连续</button>
<button id="annotationButton" class="button" type="button" data-short="注" title="隐藏注解" aria-label="隐藏注解" aria-pressed="true">注解</button>
</div>
<div class="tool-group">
<div class="tool-group" role="group" aria-labelledby="exportLabel">
<span id="exportLabel" class="tool-label">导出</span>
<select id="exportFormat" class="format-select" title="导出格式" aria-label="导出格式" disabled></select>
<select id="imageDPI" class="format-select dpi-select" title="图片 DPI" aria-label="图片 DPI" disabled>
<option value="96">96</option>
<option value="150">150</option>
<option value="300" selected>300</option>
<option value="600">600</option>
<select id="imageDPI" class="format-select dpi-select" title="导出DPI(仅PNG/JPG)" aria-label="导出DPI" disabled>
<option value="96">96 dpi</option>
<option value="150">150 dpi</option>
<option value="300" selected>300 dpi</option>
<option value="600">600 dpi</option>
</select>
<button id="exportPageButton" class="button" type="button" data-short="单" title="导出单页" aria-label="导出单页" disabled>单页</button>
<button id="exportButton" class="button" type="button" data-short="多" title="导出文档" aria-label="导出文档" aria-haspopup="dialog" aria-controls="exportPanel" disabled>文档</button>
@@ -434,21 +435,23 @@
<section id="renderPanel" class="render-panel" aria-label="渲染">
<div class="panel-row">
<label class="panel-title compact" for="renderBackend">渲染</label>
<div class="render-controls">
<select id="renderBackend" title="渲染后端" disabled></select>
</div>
</div>
<div class="render-mode-row">
<span id="renderModeLabel">显示</span>
<span id="renderModeLabel">预览</span>
<div class="render-controls">
<div class="render-modes" role="group" aria-labelledby="renderModeLabel">
<button id="renderVectorButton" class="small-button" type="button" aria-pressed="true" title="矢量显示" disabled>矢量</button>
<button id="renderRasterButton" class="small-button" type="button" aria-pressed="false" title="位图显示" disabled>位图</button>
</div>
<select id="renderDPI" aria-label="精度" title="位图精度" disabled><option value="96">96 dpi</option><option value="150" selected>150 dpi</option><option value="300">300 dpi</option></select>
<select id="renderDPI" aria-label="预览DPI" title="预览DPI(仅位图)" disabled><option value="96">96 dpi</option><option value="150" selected>150 dpi</option><option value="300">300 dpi</option><option value="600">600 dpi</option></select>
</div>
</div>
<details class="render-support">
<summary>详情</summary>
<dl><div><dt>显示</dt><dd id="renderDisplay">SVG</dd></div><div><dt>编辑</dt><dd>SVG</dd></div><div><dt>编译</dt><dd id="renderCompiler"></dd></div><div><dt>图片</dt><dd id="renderImages"></dd></div><div><dt>SVG</dt><dd id="renderSVG"></dd></div><div><dt>PDF</dt><dd id="renderPDF"></dd></div><div><dt>EPS</dt><dd id="renderEPS"></dd></div></dl>
<dl><div><dt>显示</dt><dd id="renderDisplay">SVG</dd></div><div><dt>编辑</dt><dd>SVG</dd></div><div><dt>字体</dt><dd id="renderFonts"></dd></div><div><dt>几何</dt><dd id="renderGeometry"></dd></div><div><dt>编译</dt><dd id="renderCompiler"></dd></div><div><dt>图片</dt><dd id="renderImages"></dd></div><div><dt>SVG</dt><dd id="renderSVG"></dd></div><div><dt>PDF</dt><dd id="renderPDF"></dd></div><div><dt>EPS</dt><dd id="renderEPS"></dd></div></dl>
</details>
</section>
<div class="panel-row">
+17 -17
View File
@@ -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;
}
}
+6
View File
@@ -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() {
+10 -9
View File
@@ -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 {
+6 -1
View File
@@ -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)
-1
View File
@@ -79,7 +79,6 @@ func NewRenderer(reader *Reader, opts ...RendererOption) *Renderer {
for _, opt := range opts {
opt(r)
}
r.initCommon()
return r
}
+40 -85
View File
@@ -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
}
+10 -3
View File
@@ -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应用
+123
View File
@@ -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
}
+28 -96
View File
@@ -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 文档内嵌资源
@@ -133,7 +147,8 @@ func NewEditor() *Editor {
fonts: make(map[string]*font.SFNT),
images: make(map[string]image.Point),
resourceID: make(map[editorResourceKey]string),
compiler: CanvasBackend{},
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 {
if err := LayoutText(obj, value, options, metrics); 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 {
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
}
+13 -5
View File
@@ -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 = &copy, 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
}
+6 -44
View File
@@ -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(&copy.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 错误信息
+63 -27
View File
@@ -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
+3
View File
@@ -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
}
+25 -28
View File
@@ -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.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
}
}
if data == nil && e.source != nil {
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)}
}
data, err := e.source.reader.FontData(id)
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
}
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 获取编辑使用的字体数据,外部字体不写入文档资源
+10
View File
@@ -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渲染
+75
View File
@@ -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
}
+4 -5
View File
@@ -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 {
+3
View File
@@ -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)
+96
View File
@@ -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}},
}
}
+71
View File
@@ -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
}
+307
View File
@@ -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
}
+46 -22
View File
@@ -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
if !pressure {
if i == 0 {
path.MoveTo(point.X, point.Y)
} else {
path.LineTo(point.X, point.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 {
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},
}
}
+13 -106
View File
@@ -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颜色
+115
View File
@@ -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)
}
@@ -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
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
+41 -203
View File
@@ -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
}
File renamed without changes.
-65
View File
@@ -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) {}
+249
View File
@@ -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
}
File renamed without changes.
+135
View File
@@ -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()
}
File renamed without changes.
File renamed without changes.
@@ -67,18 +67,22 @@ 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
}
@@ -99,23 +103,15 @@ func (r *Renderer) loadFont(fontID string) *canvas.FontFamily {
r.fontGIDMap[fontID] = inv
}
}
if err := ff.LoadFont(fontData, 0, fontStyle); err == nil {
}
if err := ff.LoadFont(fontData, 0, fontStyle); err != nil {
r.renderError = err
return nil
}
if r.Reader.fontCache[fontID] != nil {
r.fontMap[fontID] = ff
}
return ff
}
return nil
}
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
}
}
r.fontMap[fontID] = defaultFont
return defaultFont
}
// 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 {
+324
View File
@@ -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))
}
File renamed without changes.
+111
View File
@@ -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},
}
}
@@ -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
}
@@ -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
}
@@ -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
@@ -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
}
+95
View File
@@ -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
}
+129
View File
@@ -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
}
@@ -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 路径对象
@@ -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区域
File renamed without changes.
@@ -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
}
@@ -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(),
File renamed without changes.
+3 -17
View File
@@ -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 基本图形类型
-94
View File
@@ -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 是否存在
+122
View File
@@ -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
}