feat(渲染编辑): 修复保存、文字与渲染问题
build.yaml / build (push) Successful in 40s

This commit is contained in:
xiaoqidun committed 2026-09-23 21:05:47 +08:00
1 parent 580e7043c4
commit d6f70de7be
40 files changed
+2299 -407

No files matched your search

+1 -2
View File
@@ -43,8 +43,7 @@
<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" role="group" aria-labelledby="exportLabel">
<span id="exportLabel" class="tool-label">导出</span>
<div class="tool-group" role="group" aria-label="导出">
<select id="exportFormat" class="format-select" title="导出格式" aria-label="导出格式" disabled></select>
<select id="imageDPI" class="format-select dpi-select" title="导出DPI(仅PNG/JPG)" aria-label="导出DPI" disabled>
<option value="96">96 dpi</option>
-5
View File
@@ -623,11 +623,6 @@ button:not(:disabled):hover {
width: 79px;
}
.tool-label {
color: var(--text-muted);
white-space: nowrap;
}
.form-dialog {
inset: 0;
width: 320px;
+27 -2
View File
@@ -190,7 +190,7 @@ func WithRenderBackends(backends RenderBackends) RendererOption {
}
}
// sameBackend 判断可比较值配置是否未变,指针配置重新应用时刷新可变状态
// sameBackend 判断不可变值配置是否未变,包含指针的配置重新应用时刷新可变状态
// 入参: left 原后端, right 新后端
// 返回: bool 是否为同一配置
func sameBackend(left, right Backend) bool {
@@ -198,7 +198,32 @@ func sameBackend(left, right Backend) bool {
return left == nil && right == nil
}
a, b := reflect.ValueOf(left), reflect.ValueOf(right)
return a.Kind() != reflect.Pointer && b.Kind() != reflect.Pointer && a.Comparable() && b.Comparable() && left == right
return a.Comparable() && b.Comparable() && left == right && immutableBackendValue(a)
}
// immutableBackendValue 检查嵌套配置是否仅包含不可变值,不绑定具体后端类型
// 入参: value 配置值
// 返回: bool 是否可按值比较复用
func immutableBackendValue(value reflect.Value) bool {
switch value.Kind() {
case reflect.Pointer, reflect.UnsafePointer, reflect.Chan, reflect.Map, reflect.Slice, reflect.Func:
return false
case reflect.Interface:
return value.IsNil() || immutableBackendValue(value.Elem())
case reflect.Struct:
for i := range value.NumField() {
if !immutableBackendValue(value.Field(i)) {
return false
}
}
case reflect.Array:
for i := range value.Len() {
if !immutableBackendValue(value.Index(i)) {
return false
}
}
}
return true
}
// Backends 返回当前后端配置副本,可修改后通过WithRenderBackends应用
+5 -5
View File
@@ -592,7 +592,7 @@ func (e *Editor) AddFont(file FontFile, index int) (string, error) {
extension = ".otf"
}
resource := editorResource{
name: e.resourceDirectory() + "/Font_" + id + extension,
name: e.packageName("Res/Fonts/Font_" + id + extension),
data: data,
font: &Font{
ID: id, FontName: name,
@@ -603,7 +603,7 @@ func (e *Editor) AddFont(file FontFile, index int) (string, error) {
FixedWidth: sfnt.Post.IsFixedPitch != 0,
},
}
resource.font.FontFile = "Font_" + id + extension
resource.font.FontFile = "/" + resource.name
e.resources = append(e.resources, resource)
e.fonts[id] = sfnt
e.resourceID[key] = id
@@ -655,11 +655,11 @@ func (e *Editor) AddImage(data []byte) (string, error) {
} else if extension == "jbig2" {
extension = "jb2"
}
name := "Image_" + id + "." + extension
name := e.packageName("Res/Images/Image_" + id + "." + extension)
e.resources = append(e.resources, editorResource{
name: e.resourceDirectory() + "/" + name,
name: name,
data: bytes.Clone(data),
image: &MultiMedia{ID: id, Type: "Image", Format: strings.ToUpper(format), MediaFile: name},
image: &MultiMedia{ID: id, Type: "Image", Format: strings.ToUpper(format), MediaFile: "/" + name},
})
e.images[id] = image.Pt(config.Width, config.Height)
e.resourceID[key] = id
+4 -4
View File
@@ -178,7 +178,7 @@ func (e *Editor) editAnnotations(index int, ids []string, edit func([]byte, *edi
found := make(map[string]bool, len(ids))
remaining := 0
for _, ref := range root.children {
if ref.name.Local != "Page" || ref.name.Space != root.name.Space {
if !packageOFDNode(ref, "Page") {
continue
}
remaining++
@@ -204,7 +204,7 @@ func (e *Editor) editAnnotations(index int, ids []string, edit func([]byte, *edi
var changes []editorXMLPatch
count := 0
for _, node := range page.children {
if node.name.Local != "Annot" || node.name.Space != page.name.Space {
if !packageOFDNode(node, "Annot") {
continue
}
count++
@@ -232,7 +232,7 @@ func (e *Editor) editAnnotations(index int, ids []string, edit func([]byte, *edi
}
sharedFile := false
for _, other := range root.children {
if other == ref || other.name.Local != "Page" || other.name.Space != root.name.Space || other.child("FileLoc") == nil {
if other == ref || !packageOFDNode(other, "Page") || other.child("FileLoc") == nil {
continue
}
shared, err := editorPageLocation(reader, nil, name, strings.TrimSpace(editorImportText(data, other.child("FileLoc"))))
@@ -250,7 +250,7 @@ func (e *Editor) editAnnotations(index int, ids []string, edit func([]byte, *edi
continue
}
if sharedFile {
file = path.Join(e.source.directory, "Annotations", e.pages[index].ID, file)
file = packageAvailableName(reader, parts, e.packageName(path.Join("Annotations", "Page_"+e.pages[index].ID+".xml")))
var escaped bytes.Buffer
_ = xml.EscapeText(&escaped, []byte("/"+file))
patches = append(patches, editorXMLContent(data, loc, escaped.Bytes()))
+25 -12
View File
@@ -137,7 +137,7 @@ func (e *Editor) annotationXML(index int, id string) ([]byte, error) {
}
var result []byte
for _, ref := range root.children {
if ref.name.Space != root.name.Space || ref.name.Local != "Page" || ref.attr("PageID") != e.pages[index].ID || ref.child("FileLoc") == nil {
if !packageOFDNode(ref, "Page") || ref.attr("PageID") != e.pages[index].ID || ref.child("FileLoc") == nil {
continue
}
file, err := editorPageLocation(reader, nil, name, strings.TrimSpace(editorImportText(data, ref.child("FileLoc"))))
@@ -153,7 +153,7 @@ func (e *Editor) annotationXML(index int, id string) ([]byte, error) {
return nil, err
}
for _, node := range page.children {
if node.name.Local != "Annot" || node.name.Space != page.name.Space || node.attr("ID") != id {
if !packageOFDNode(node, "Annot") || node.attr("ID") != id {
continue
}
if result != nil {
@@ -187,7 +187,7 @@ func (e *Editor) appendAnnotations(index int, annotations []byte) error {
name := reader.ResPath(base.document.Annotations)
var data []byte
if base.document.Annotations == "" {
name = path.Join(base.directory, "Annotations.xml")
name = e.packageName("Annotations.xml")
data, err = editorXMLContainer("Annotations", nil, nil)
} else {
if file, ok := reader.packageFile(name); ok {
@@ -209,7 +209,7 @@ func (e *Editor) appendAnnotations(index int, annotations []byte) error {
return err
}
for _, item := range root.children {
if item.name.Local == "Page" && item.name.Space == root.name.Space && item.attr("PageID") == e.pages[index].ID {
if packageOFDNode(item, "Page") && item.attr("PageID") == e.pages[index].ID {
ref = item
loc := item.child("FileLoc")
if loc == nil {
@@ -240,7 +240,7 @@ func (e *Editor) appendAnnotations(index int, annotations []byte) error {
file := existing
if file != "" {
for _, other := range root.children {
if other == ref || other.name.Local != "Page" || other.name.Space != root.name.Space || other.child("FileLoc") == nil {
if other == ref || !packageOFDNode(other, "Page") || other.child("FileLoc") == nil {
continue
}
shared, err := editorPageLocation(reader, nil, name, strings.TrimSpace(editorImportText(data, other.child("FileLoc"))))
@@ -251,13 +251,15 @@ func (e *Editor) appendAnnotations(index int, annotations []byte) error {
}
}
if file == "" {
file = path.Join(base.directory, "Annotations", e.nextID()+".xml")
file = e.packageName(path.Join("Annotations", "Page_"+e.pages[index].ID+".xml"))
}
if ref != nil {
loc := ref.child("FileLoc")
var value bytes.Buffer
_ = xml.EscapeText(&value, []byte("/"+file))
data = editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, loc, value.Bytes())})
if file != existing {
loc := ref.child("FileLoc")
var value bytes.Buffer
_ = xml.EscapeText(&value, []byte("/"+file))
data = editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, loc, value.Bytes())})
}
} else {
entry, err := editorXMLContainer("Page", ofdAttrs{{Name: xml.Name{Local: "PageID"}, Value: e.pages[index].ID}}, editorXMLText("FileLoc", "/"+file))
if err != nil {
@@ -288,7 +290,13 @@ func (e *Editor) appendAnnotations(index int, annotations []byte) error {
break
}
}
parts[docName] = editorPatchXML(docData, []editorXMLPatch{{position, position, editorXMLText("Annotations", "/"+name)}})
patch := editorXMLPatch{position, position, editorXMLText("Annotations", "/"+name)}
if node := doc.child("Annotations"); node != nil {
var value bytes.Buffer
_ = xml.EscapeText(&value, []byte("/"+name))
patch = editorXMLContent(docData, node, value.Bytes())
}
parts[docName] = editorPatchXML(docData, []editorXMLPatch{patch})
}
if err := e.commitAnnotationParts(base, parts); err != nil {
return err
@@ -335,7 +343,12 @@ func (e *Editor) commitAnnotationParts(base *editorSource, parts map[string][]by
if files == nil {
files = make(map[string][]byte)
}
maps.Copy(files, parts)
for name, data := range parts {
if file, ok := base.reader.packageFile(name); ok {
name = cleanPackagePath(file.Name)
}
files[name] = data
}
reader := &Reader{Zip: base.reader.Zip, files: files}
if err := reader.initRoot(); err != nil {
return err
+2 -2
View File
@@ -35,7 +35,7 @@ type ObjectPath struct {
}
// CompositeMember 复合对象的直接成员快照,Object包含有效样式,不用于整体替换原对象
// Bounds与Contours使用页面毫米坐标,Matrix将对象局部坐标映射至页面,StrokeScale为描边倍率
// Bounds与Contours使用页面毫米坐标,StrokeScale为描边倍率,Matrix将对象局部坐标映射至页面
// Position表示当前范围内的直接容器位置,结构操作后需重新枚举
type CompositeMember struct {
Object GraphicObject
@@ -1033,7 +1033,7 @@ func (e *Editor) compositeResource(id string, data []byte) (editorResource, erro
if err != nil {
return editorResource{}, err
}
return editorResource{name: e.resourceDirectory() + "/Composite_" + id + ".xml", data: data, composite: id, references: refs}, nil
return editorResource{name: e.packageName("Res/Composites/Composite_" + id + ".xml"), data: data, composite: id, references: refs}, nil
}
// editorResourceReferences 收集标准资源XML中的对象引用
+1 -1
View File
@@ -283,7 +283,7 @@ func (e *Editor) addEditorDrawParam(draw DrawParam) (string, error) {
if err != nil {
return "", err
}
resource := editorResource{name: e.resourceDirectory() + "/DrawParam_" + draw.ID + ".xml", data: data, draw: &draw, references: refs}
resource := editorResource{name: e.packageName("Res/DrawParams/DrawParam_" + draw.ID + ".xml"), data: data, draw: &draw, references: refs}
e.resources = append(e.resources, resource)
return draw.ID, nil
}
+1 -1
View File
@@ -125,6 +125,6 @@ func (e *Editor) copyDrawParam(id, base string, visited map[string]bool) (string
if err != nil {
return "", err
}
e.resources = append(e.resources, editorResource{name: e.resourceDirectory() + "/DrawParam_" + draw.ID + ".xml", data: data, draw: &draw, drawSource: id, references: refs})
e.resources = append(e.resources, editorResource{name: e.packageName("Res/DrawParams/DrawParam_" + draw.ID + ".xml"), data: data, draw: &draw, drawSource: id, references: refs})
return draw.ID, nil
}
+225 -72
View File
@@ -37,7 +37,14 @@ type editorImportEntry struct {
type editorPageImport struct {
reader *Reader
doc *Document
prefix string
directory string
target *Reader
paths map[string]string
evidence map[string]string
resourceXML []byte
pageParts []byte
documentXML []byte
documentRoot *editorXML
maximum int
ids map[string]string
pages map[string]bool
@@ -116,12 +123,20 @@ func (e *Editor) importPages(source *Reader, indexes []int, at int, copyPage boo
return nil, err
}
}
prefix := path.Join(base.directory, "Import_"+strconv.Itoa(maximum+1))
for editorDirectoryExists(base.reader, prefix) {
prefix += "_"
}
m := &editorPageImport{reader: source, doc: doc, prefix: prefix, maximum: maximum, copyPage: copyPage, progress: progress,
m := &editorPageImport{reader: source, doc: doc, directory: e.packageDirectory(), target: base.reader, maximum: maximum, copyPage: copyPage, progress: progress,
paths: make(map[string]string), evidence: make(map[string]string),
ids: make(map[string]string), pages: selected, files: make(map[string][]byte), resources: make(map[string]editorImportEntry), used: make(map[string][]byte), templates: make(map[string]TemplatePage), templateParts: make(map[string][]byte), attachments: make(map[string][]byte)}
for _, resource := range e.resources {
m.files[resource.name] = nil
}
m.documentXML, err = m.readFile(source.ResPath(source.OFD.DocBody[0].DocRoot))
if err != nil {
return nil, err
}
m.documentRoot, err = parseEditorXML(m.documentXML)
if err != nil {
return nil, err
}
if copyPage {
for _, page := range doc.Pages.Page {
if !selected[page.ID] {
@@ -155,11 +170,16 @@ func (e *Editor) importPages(source *Reader, indexes []int, at int, copyPage boo
}
page := doc.Pages.Page[index]
ids[i] = m.id(page.ID)
name, err := m.copyXML(source.ResPath(page.BaseLoc))
name, err := m.copyXML(source.ResPath(page.BaseLoc), packagePagePath(m.directory, ids[i]))
if err != nil {
return nil, err
}
refs[i] = Page{ID: ids[i], BaseLoc: name}
entry, err := m.documentReference("Pages", "Page", page.ID, ids[i], name)
if err != nil {
return nil, err
}
m.pageParts = append(m.pageParts, entry...)
}
if err := progress.report("pages", len(indexes), len(indexes)); err != nil {
return nil, err
@@ -205,7 +225,12 @@ func (e *Editor) importPages(source *Reader, indexes []int, at int, copyPage boo
if err != nil {
return nil, err
}
m.files[path.Join(prefix, "Resources.xml")] = resources
if len(resourceContent) != 0 {
m.resourceXML = resources
}
}
for _, resource := range e.resources {
delete(m.files, resource.name)
}
next, err := m.merge(base, refs, annotations, signatures)
if err != nil {
@@ -387,6 +412,27 @@ func (m *editorPageImport) outlines() ([]byte, error) {
if len(editorOutlineChildren(root)) == 0 {
return nil, nil
}
var validate func(*editorXML) error
validate = func(node *editorXML) error {
for _, attr := range node.attrs {
if attr.Name.Space == "" || attr.Name.Space == "xmlns" {
continue
}
if attr.Name.Space == "http://www.w3.org/2001/XMLSchema-instance" && (attr.Name.Local == "schemaLocation" || attr.Name.Local == "noNamespaceSchemaLocation") {
continue
}
return fmt.Errorf("cannot remap outline extension attribute %q", attr.Name.Local)
}
for _, child := range node.children {
if err := validate(child); err != nil {
return err
}
}
return nil
}
if err := validate(root); err != nil {
return nil, err
}
return m.encode(editorImportEntry{name: name, data: data}, root)
}
@@ -522,11 +568,11 @@ func (m *editorPageImport) reference(value string) (string, error) {
if template, ok := m.templates[value]; ok {
if _, seen := m.templateParts[value]; !seen {
m.templateParts[value] = nil
name, err := m.copyXML(m.reader.ResPath(template.BaseLoc))
name, err := m.copyXML(m.reader.ResPath(template.BaseLoc), path.Join(m.directory, "Templates", "Template_"+m.id(value), "Content.xml"))
if err != nil {
return "", err
}
data, err := editorXMLContainer("TemplatePage", ofdAttrs{{Name: xml.Name{Local: "ID"}, Value: m.id(value)}, {Name: xml.Name{Local: "Name"}, Value: template.Name}, {Name: xml.Name{Local: "BaseLoc"}, Value: name}, {Name: xml.Name{Local: "ZOrder"}, Value: template.ZOrder}}, nil)
data, err := m.documentReference("CommonData", "TemplatePage", value, m.id(value), name)
if err != nil {
return "", err
}
@@ -536,6 +582,35 @@ func (m *editorPageImport) reference(value string) (string, error) {
return m.id(value), nil
}
// documentReference 迁移页面或模板引用,只替换标识与路径并保留未知属性
// 入参: group 父节点名称, kind 引用节点名称, source 原标识, id 新标识, loc 新路径
// 返回: []byte 引用XML, error 错误信息
func (m *editorPageImport) documentReference(group, kind, source, id, loc string) ([]byte, error) {
if parent := m.documentRoot.child(group); parent != nil {
for _, node := range parent.children {
if node.name.Local != kind || node.attr("ID") != source {
continue
}
data, err := editorXMLStandalone(m.documentXML[node.start:node.end], node)
if err != nil {
return nil, err
}
for _, attr := range [][2]string{{"ID", id}, {"BaseLoc", loc}} {
root, err := parseEditorXML(data)
if err != nil {
return nil, err
}
data, err = editorXMLAttribute(data, root, attr[0], attr[1])
if err != nil {
return nil, err
}
}
return data, nil
}
}
return nil, fmt.Errorf("missing %s reference %q", kind, source)
}
// resolve 按所在XML解析包内路径,兼容阅读器接受的文档相对路径
// 入参: name 所在XML, base 资源目录, value 路径值
// 返回: string 包内路径, error 错误信息
@@ -551,33 +626,40 @@ func (m *editorPageImport) resolve(name, base, value string) (string, error) {
return "", fmt.Errorf("import reference %q in %s not found", value, name)
}
// copyData 复制二进制资源或签名引用原文,返回绝对包路径
// 入参: name 来源路径, evidence 是否原始签名凭据
// copyData 复制二进制资源或签名引用原文,凭据与当前引用使用独立映射
// 入参: name 来源路径, relative 文档内候选路径, evidence 是否原始签名凭据
// 返回: string 新路径, error 错误信息
func (m *editorPageImport) copyData(name string, evidence bool) (string, error) {
directory := m.prefix
func (m *editorPageImport) copyData(name, relative string, evidence bool) (string, error) {
paths := m.paths
if evidence {
directory = path.Join(directory, "Signed")
paths = m.evidence
relative = path.Join("Signs", "Signed", path.Base(name))
}
target := path.Join(directory, name)
if _, exists := m.files[target]; !exists {
data, err := m.readFile(name)
if err != nil {
return "", err
}
m.files[target] = data
key := strings.ToLower(cleanPackagePath(name))
if target := paths[key]; target != "" {
return "/" + target, nil
}
if m.copyPage && !evidence {
return "/" + cleanPackagePath(name), nil
}
target := packageAvailableName(m.target, m.files, path.Join(m.directory, relative))
data, err := m.readFile(name)
if err != nil {
return "", err
}
paths[key], m.files[target] = target, data
return "/" + target, nil
}
// copyXML 复制页面或标准关联XML并重映射引用
// 入参: name 来源路径
// 入参: name 来源路径, candidate 目标候选路径
// 返回: string 新路径, error 错误信息
func (m *editorPageImport) copyXML(name string) (string, error) {
target := path.Join(m.prefix, name)
if _, exists := m.files[target]; exists {
func (m *editorPageImport) copyXML(name, candidate string) (string, error) {
key := strings.ToLower(cleanPackagePath(name))
if target := m.paths[key]; target != "" {
return "/" + target, nil
}
target := packageAvailableName(m.target, m.files, candidate)
data, err := m.readFile(name)
if err != nil {
return "", err
@@ -586,7 +668,7 @@ func (m *editorPageImport) copyXML(name string) (string, error) {
if err != nil {
return "", err
}
m.files[target] = nil
m.paths[key], m.files[target] = target, nil
if root.name.Local == "Page" {
for _, child := range root.children {
if child.name.Local == "PageRes" {
@@ -660,11 +742,9 @@ func (m *editorPageImport) encode(entry editorImportEntry, node *editorXML) ([]b
if attr.Name.Space == "xmlns" || attr.Name.Local == "xmlns" {
continue
}
if attr.Name.Space == "http://www.w3.org/2001/XMLSchema-instance" && (attr.Name.Local == "schemaLocation" || attr.Name.Local == "noNamespaceSchemaLocation") {
continue
}
if attr.Name.Space != "" {
return nil, fmt.Errorf("cannot remap extension attribute %q", attr.Name.Local)
attrs = append(attrs, attr)
continue
}
key, value := attr.Name.Local, attr.Value
var err error
@@ -682,9 +762,13 @@ func (m *editorPageImport) encode(entry editorImportEntry, node *editorXML) ([]b
return nil, resolveErr
}
if key == "FileRef" {
value, err = m.copyData(loc, true)
value, err = m.copyData(loc, "", true)
} else {
value, err = m.copyXML(loc)
directory := "Res"
if name == "Signature" {
directory = path.Join("Signs", "Sign_"+m.id("Signature:"+node.attr("ID")))
}
value, err = m.copyXML(loc, path.Join(m.directory, directory, path.Base(loc)))
}
case "AttachID":
value, err = m.attachment(value)
@@ -698,40 +782,39 @@ func (m *editorPageImport) encode(entry editorImportEntry, node *editorXML) ([]b
attrs = append(attrs, xml.Attr{Name: xml.Name{Local: "ColorSpace"}, Value: m.defaultCS})
}
var content []byte
if name == "Page" {
pageContent := name == "Page" && node.parent == nil
if pageContent {
area := m.doc.CommonData.PageArea
if local := node.child("Area"); local != nil {
var own PageArea
if err := xml.Unmarshal(entry.data[local.start:local.end], &own); err != nil {
return nil, err
}
if own.PhysicalBox != "" {
area.PhysicalBox = own.PhysicalBox
}
if own.ApplicationBox != "" {
area.ApplicationBox = own.ApplicationBox
}
if own.ContentBox != "" {
area.ContentBox = own.ContentBox
}
if own.BleedBox != "" {
area.BleedBox = own.BleedBox
}
}
local := node.child("Area")
var fields []byte
for _, field := range [][2]string{{"PhysicalBox", area.PhysicalBox}, {"ApplicationBox", area.ApplicationBox}, {"ContentBox", area.ContentBox}, {"BleedBox", area.BleedBox}} {
if field[1] != "" {
if field[1] != "" && (local == nil || local.child(field[0]) == nil) {
fields = append(fields, editorXMLText(field[0], field[1])...)
}
}
encoded, err := editorXMLContainer("Area", nil, fields)
if err != nil {
return nil, err
if local == nil {
encoded, err := editorXMLContainer("Area", nil, fields)
if err != nil {
return nil, err
}
content = encoded
} else {
encoded, err := m.encode(entry, local)
if err != nil {
return nil, err
}
root, err := parseEditorXML(encoded)
if err != nil {
return nil, err
}
if root.open != root.end {
fields = append(bytes.Clone(encoded[root.open:root.close]), fields...)
}
content = editorPatchXML(encoded, []editorXMLPatch{editorXMLContent(encoded, root, fields)})
}
content = bytes.TrimPrefix(encoded, []byte(xml.Header))
}
for _, child := range node.children {
if name == "Page" && child.name.Local == "Area" {
if pageContent && child.name.Local == "Area" {
continue
}
if name == "Goto" && child.name.Local == "Bookmark" {
@@ -774,16 +857,38 @@ func (m *editorPageImport) encode(entry editorImportEntry, node *editorXML) ([]b
if resolveErr != nil {
return nil, resolveErr
}
value, err = m.copyData(loc, false)
directory := "Res"
file := path.Base(loc)
owner := node.parent.attr("ID")
switch name {
case "FontFile":
directory = "Res/Fonts"
if owner != "" {
file = "Font_" + m.id(owner) + strings.ToLower(path.Ext(loc))
}
case "MediaFile":
directory = "Res/Images"
if node.parent.attr("Type") != "Image" {
directory = "Res/Media"
} else if owner != "" {
file = "Image_" + m.id(owner) + strings.ToLower(path.Ext(loc))
}
case "Profile":
directory = "Res/ColorSpaces"
case "SignedValue", "BaseLoc":
directory = "Signs"
}
value, err = m.copyData(loc, path.Join(directory, file), false)
case "FileLoc":
loc, resolveErr := m.resolve(entry.name, "", strings.TrimSpace(value))
if resolveErr != nil {
return nil, resolveErr
}
if node.parent.name.Local == "Attachment" {
value, err = m.copyData(loc, false)
value, err = m.copyData(loc, path.Join("Attachments", path.Base(loc)), false)
} else {
value, err = m.copyXML(loc)
candidate := path.Join(m.directory, "Annotations", "Page_"+m.id(node.parent.attr("PageID"))+".xml")
value, err = m.copyXML(loc, candidate)
}
case "Font", "Substitution":
value, err = m.reference(strings.TrimSpace(value))
@@ -924,6 +1029,9 @@ func (m *editorPageImport) merge(base *editorSource, refs []Page, annotations, s
}
maps.Copy(files, m.files)
name := base.reader.ResPath(base.reader.OFD.DocBody[0].DocRoot)
if file, ok := base.reader.packageFile(name); ok {
name = cleanPackagePath(file.Name)
}
data, err := base.reader.readFile(name)
if err != nil {
return nil, err
@@ -944,20 +1052,41 @@ func (m *editorPageImport) merge(base *editorSource, refs []Page, annotations, s
}
}
var content []byte
if m.files[path.Join(m.prefix, "Resources.xml")] != nil {
content = editorXMLText("DocumentRes", "/"+path.Join(m.prefix, "Resources.xml"))
if len(m.resourceXML) != 0 {
name, added, err := mergePackageResources(base.reader, files, base.document, m.directory, m.resourceXML)
if err != nil {
return nil, err
}
if added {
content = editorXMLText("DocumentRes", "/"+strings.TrimPrefix(name, "/"))
}
}
for _, id := range slices.Sorted(maps.Keys(m.templateParts)) {
content = append(content, m.templateParts[id]...)
}
data = editorPatchXML(data, []editorXMLPatch{{position, position, content}})
root, err = parseEditorXML(data)
if err != nil {
return nil, err
}
pages := root.child("Pages")
if pages == nil {
return nil, fmt.Errorf("document has no Pages")
}
content = bytes.Clone(m.pageParts)
if pages.open != pages.end {
content = append(bytes.Clone(data[pages.open:pages.close]), content...)
}
data = editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, pages, content)})
if len(annotations) != 0 {
loc, err := mergeImportIndex(base.reader, files, base.document.Annotations, path.Join(m.prefix, "Annotations.xml"), "Annotations", annotations)
loc, err := mergeImportIndex(base.reader, files, base.document.Annotations, path.Join(m.directory, "Annotations.xml"), "Annotations", annotations)
if err != nil {
return nil, err
}
root, _ = parseEditorXML(data)
data = editorXMLSetText(data, root, [][2]string{{"Annotations", loc}})
if base.document.Annotations == "" {
root, _ = parseEditorXML(data)
data = editorXMLSetText(data, root, [][2]string{{"Annotations", loc}})
}
}
if len(m.attachments) != 0 {
var entries []byte
@@ -970,16 +1099,28 @@ func (m *editorPageImport) merge(base *editorSource, refs []Page, annotations, s
content := append(bytes.Clone(data[node.open:node.close]), entries...)
data = editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, node, content)})
} else {
loc, err := mergeImportIndex(base.reader, files, base.document.Attachments.Path, path.Join(m.prefix, "Attachments.xml"), "Attachments", entries)
loc, err := mergeImportIndex(base.reader, files, base.document.Attachments.Path, path.Join(m.directory, "Attachments.xml"), "Attachments", entries)
if err != nil {
return nil, err
}
data = editorXMLSetText(data, root, [][2]string{{"Attachments", loc}})
if base.document.Attachments.Path == "" {
data = editorXMLSetText(data, root, [][2]string{{"Attachments", loc}})
}
}
}
root, err = parseEditorXML(data)
if err != nil {
return nil, err
}
common = root.child("CommonData")
patch := editorXMLPatch{common.open, common.open, editorXMLText("MaxUnitID", strconv.Itoa(m.maximum))}
if maximum := common.child("MaxUnitID"); maximum != nil {
patch = editorXMLContent(data, maximum, []byte(strconv.Itoa(m.maximum)))
}
data = editorPatchXML(data, []editorXMLPatch{patch})
files[name] = data
if len(signatures) != 0 {
loc, err := mergeImportIndex(base.reader, files, base.document.Signatures, path.Join(m.prefix, "Signatures.xml"), "Signatures", signatures)
loc, err := mergeImportIndex(base.reader, files, base.document.Signatures, path.Join(m.directory, "Signs", "Signatures.xml"), "Signatures", signatures)
if err != nil {
return nil, err
}
@@ -992,7 +1133,11 @@ func (m *editorPageImport) merge(base *editorSource, refs []Page, annotations, s
if err != nil {
return nil, err
}
files["OFD.xml"] = editorXMLSetText(ofd, root.child("DocBody"), [][2]string{{"Signatures", loc}})
name := "OFD.xml"
if file, ok := base.reader.packageFile(name); ok {
name = cleanPackagePath(file.Name)
}
files[name] = editorXMLSetText(ofd, root.child("DocBody"), [][2]string{{"Signatures", loc}})
}
}
reader := &Reader{Zip: base.reader.Zip, files: files}
@@ -1026,10 +1171,18 @@ func mergeImportIndex(reader *Reader, files map[string][]byte, loc, fallback, ro
var data []byte
var err error
if loc == "" {
name = packageAvailableName(reader, files, fallback)
data, err = editorXMLContainer(rootName, nil, entries)
} else {
name = reader.ResPath(loc)
data, err = reader.readFile(name)
name, err = editorPageLocation(reader, files, "", "/"+reader.ResPath(loc))
if err != nil {
return "", err
}
var exists bool
data, exists = files[name]
if !exists {
data, err = reader.readFile(name)
}
if err != nil {
return "", err
}
+1 -30
View File
@@ -144,10 +144,7 @@ func (r *Reader) Editor() (*Editor, error) {
}
e := NewEditor()
e.Info, e.maxID = cloneEditorData(*info), maximum
directory := path.Join(cleanPackagePath(reader.RootDir), "Edit")
for i := 1; editorDirectoryExists(reader, directory); i++ {
directory = path.Join(cleanPackagePath(reader.RootDir), "Edit_"+strconv.Itoa(i))
}
directory := cleanPackagePath(reader.RootDir)
e.source = &editorSource{reader: reader, document: doc, info: cloneEditorData(*info), directory: directory, pages: make(map[string]*editorSourcePage), origins: make(map[string]*editorObjectOrigin)}
for _, page := range doc.Pages.Page {
if page.ID == "" || e.source.pages[page.ID] != nil {
@@ -159,23 +156,6 @@ func (r *Reader) Editor() (*Editor, error) {
return e, nil
}
// editorDirectoryExists 判断包内目录是否已经使用,避免覆盖原始资源
// 入参: reader 输入包, directory 目录路径
// 返回: bool 是否存在
func editorDirectoryExists(reader *Reader, directory string) bool {
for name := range reader.fileIndex {
if strings.EqualFold(name, directory) || strings.HasPrefix(strings.ToLower(name), strings.ToLower(directory)+"/") {
return true
}
}
for name := range reader.files {
if strings.EqualFold(name, directory) || strings.HasPrefix(strings.ToLower(name), strings.ToLower(directory)+"/") {
return true
}
}
return false
}
// prepareSourceIDs 首次新增内容前流式核对原包标识,不依赖可能缺失或过期的MaxUnitID
// 返回: error 错误信息
func (e *Editor) prepareSourceIDs() error {
@@ -262,15 +242,6 @@ func editorXMLMaxID(input io.Reader) (int, error) {
}
}
// resourceDirectory 获取新增二进制资源的目录
// 返回: string 包内路径
func (e *Editor) resourceDirectory() string {
if e.source != nil {
return e.source.directory + "/Res"
}
return "Doc_0/Res"
}
// originalPage 判断索引是否指向原文档页面
// 入参: index 页面索引
// 返回: bool 是否为原始页面
+32 -10
View File
@@ -21,7 +21,6 @@ import (
"fmt"
"io"
"maps"
"path"
"reflect"
"slices"
"strconv"
@@ -54,7 +53,7 @@ func (e *Editor) sourceParts(progress editorProgress) (map[string][]byte, error)
parts[reader.ResPath(original.ref.BaseLoc)] = data
}
} else {
name := path.Join(source.directory, "Pages", page.ID+".xml")
name := e.packageName(packagePagePath("", page.ID))
data, err := e.sourceNewPageXML(page)
if err != nil {
return nil, err
@@ -79,13 +78,17 @@ func (e *Editor) sourceParts(progress editorProgress) (map[string][]byte, error)
})
}
if len(fonts)+len(images)+len(spaces) != 0 {
resourcePath := path.Join(source.directory, "Resources.xml")
resourceFiles = append(resourceFiles, resourcePath)
data, err := encodeOFDXML(func(x *ofdXML) { x.resources(fonts, images, spaces) })
if err != nil {
return nil, err
}
parts[resourcePath] = data
resourcePath, added, err := mergePackageResources(reader, parts, source.document, source.directory, data)
if err != nil {
return nil, err
}
if added {
resourceFiles = append(resourceFiles, resourcePath)
}
for _, resource := range append(fonts, images...) {
if resource.name != "" {
parts[resource.name] = resource.data
@@ -135,17 +138,17 @@ func (e *Editor) sourceParts(progress editorProgress) (map[string][]byte, error)
if pages == nil {
return nil, fmt.Errorf("document has no Pages")
}
var encoded bytes.Buffer
var entries [][]byte
for _, page := range pageRefs {
var original *editorXML
for _, child := range pages.children {
if child.attr("ID") == page.ID {
if packageOFDNode(child, "Page") && child.attr("ID") == page.ID {
original = child
break
}
}
if original != nil {
encoded.Write(data[original.start:original.end])
entries = append(entries, data[original.start:original.end])
} else {
item, err := encodeOFDXML(func(x *ofdXML) {
x.root("Page", ofdAttrs{{Name: xml.Name{Local: "ID"}, Value: page.ID}, {Name: xml.Name{Local: "BaseLoc"}, Value: page.BaseLoc}})
@@ -154,10 +157,29 @@ func (e *Editor) sourceParts(progress editorProgress) (map[string][]byte, error)
if err != nil {
return nil, err
}
encoded.Write(bytes.TrimPrefix(item, []byte(xml.Header)))
entries = append(entries, bytes.TrimPrefix(item, []byte(xml.Header)))
}
}
position := 0
for _, child := range pages.children {
if !packageOFDNode(child, "Page") {
continue
}
var entry []byte
if position < len(entries) {
entry = entries[position]
position++
}
patches = append(patches, editorXMLPatch{child.start, child.end, entry})
}
if position < len(entries) {
added := bytes.Join(entries[position:], nil)
if pages.open == pages.end {
patches = append(patches, editorXMLContent(data, pages, added))
} else {
patches = append(patches, editorXMLPatch{pages.close, pages.close, added})
}
}
patches = append(patches, editorXMLContent(data, pages, encoded.Bytes()))
}
updated := editorPatchXML(data, patches)
if !bytes.Equal(data, updated) {
+8 -4
View File
@@ -59,13 +59,16 @@ func missingGlyphError(id string, characters []rune) error {
return &MissingGlyphError{FontID: id, Characters: value.String()}
}
// TextLayout 本地横向段落选项,Wrap按CTM变换前的边界宽度折行,Align为left、center、right或justify
// TextLayout 本地横向段落选项
// Shape显式启用FontShaper,保存为标准TextCode和CGTransform,不改变阅读时的原文定位
// Shaping提供可比较的塑形选项,非零值也会启用塑形,并随会话内文字编辑保留
// Wrap按CTM变换前的边界宽度折行,Align为left、center、right或justify
// LineHeight为毫米单位的基线间距,0使用字体度量;LetterSpacing为字素间的附加毫米间距,可为负
// LeftIndent和RightIndent为左右缩进,FirstLineIndent为每段首行相对左缩进的偏移,单位为毫米
// 零值保持显式换行和左对齐
// Shape显式启用FontShaper,保存为标准TextCode和CGTransform,不改变阅读时的原文定位
type TextLayout struct {
Shape bool
Shaping TextShapeOptions
Wrap bool
Align string
LineHeight float64
@@ -75,8 +78,9 @@ type TextLayout struct {
FirstLineIndent float64
}
// TextStyle 文字样式增量,Font和Color为空、Size为0时保留原值
// Size单位为毫米,Color为OFD的RGB分量字符串,保留原颜色透明度
// TextStyle 文字样式增量
// Font为空时保留原字体,Size单位为毫米且为0时保留原字号
// Color为OFD的RGB分量字符串,空值保留原颜色,修改时保留原透明度
type TextStyle struct {
Font string
Size float64
+34 -9
View File
@@ -35,6 +35,13 @@ type FontOutlines interface {
GlyphOutline(glyph uint16, size float64) (GeometryPath, error)
}
// FontOutlineBatch 批量提取指定字形,不经过字符映射或文字塑形
// 返回路径与输入编号一一对应,允许重复编号和空轮廓,路径只读
type FontOutlineBatch interface {
FontOutlines
GlyphOutlines(glyphs []uint16, size float64) ([]GeometryPath, error)
}
// FontShaper 提供可选的横向文字塑形,仅用于显式新建或重排
// 字形按逻辑簇顺序排列,Cluster使用原文的符文索引,坐标为毫米且纵轴向下
type FontShaper interface {
@@ -42,6 +49,24 @@ type FontShaper interface {
ShapeText(value string, size float64) ([]ShapedGlyph, error)
}
// TextShapeOptions 指定单一方向文字的塑形参数,不执行双向段落分析或字体回退
// Direction为空或ltr时从左向右,rtl时从右向左;Script为四字母ISO15924代码,空值自动检测
// Language为语言标签,Features为逗号分隔的全局OpenType特性,如kern=0,liga=1,不接受字符区间
// 零值保持原有FontShaper行为,非零值要求FontShaperOptions能力
type TextShapeOptions struct {
Direction string
Script string
Language string
Features string
}
// FontShaperOptions 提供显式可配置塑形,簇与坐标遵循FontShaper约定
// 右向左文字仍按逻辑簇返回,视觉顺序由X和Y保留
type FontShaperOptions interface {
FontShaper
ShapeTextWithOptions(value string, size float64, options TextShapeOptions) ([]ShapedGlyph, error)
}
// ShapedGlyph 保存塑形后的字形、原文簇和基线定位
type ShapedGlyph struct {
Glyph uint16
@@ -57,6 +82,15 @@ type ResolvedFont struct {
Exact bool
}
// PreparedFont 保存绘制用字体及GID、CID到包装字符的映射,所有数据只读
// Data可与源编码不同,原始资源仍由ResolveFont和Reader保留
type PreparedFont struct {
ResolvedFont
Glyphs map[uint16]rune
CIDs map[uint16]rune
digest [32]byte
}
// FontBackend 统一字体来源与度量,exact禁止使用无关回退字体
// ResolveFont不得递归调用Renderer.ResolveFont,OpenFont不得修改输入数据
type FontBackend interface {
@@ -118,15 +152,6 @@ func (r *Renderer) ResolveFont(id string, exact bool) (ResolvedFont, error) {
return resolved, err
}
// PreparedFont 保存绘制用字体及GID、CID到包装字符的映射,所有数据只读
// Data可与源编码不同,原始资源仍由ResolveFont和Reader保留
type PreparedFont struct {
ResolvedFont
Glyphs map[uint16]rune
CIDs map[uint16]rune
digest [32]byte
}
// PrepareFont 统一处理内嵌字体包装和索引映射,供各编译器复用
// 入参: id 字体资源标识
// 返回: *PreparedFont 绘制字体, error 字体解析错误
+73 -22
View File
@@ -19,10 +19,19 @@ import (
"math"
)
// geometryArc 保存中心式椭圆及有向参数区间
type geometryArc struct {
center, u, v Point
theta, delta float64
}
// Curves 按毫米误差上限将端点式椭圆弧转换为三次贝塞尔曲线,其他指令保持原样
// 入参: tolerance 正有限误差上限
// 返回: GeometryPath 独立路径, error 参数错误
func (p GeometryPath) Curves(tolerance float64) (GeometryPath, error) {
if err := p.validate(); err != nil {
return nil, err
}
if !rasterPositive(tolerance) {
return nil, fmt.Errorf("invalid curve tolerance")
}
@@ -58,17 +67,53 @@ func (p GeometryPath) Curves(tolerance float64) (GeometryPath, error) {
// 入参: start 起点, arc 弧线, tolerance 毫米误差上限
// 返回: GeometryPath 贝塞尔片段, error 无效弧线
func arcCurves(start Point, arc GeometrySegment, tolerance float64) (GeometryPath, error) {
rx, ry := arc.RadiusX, arc.RadiusY
if !finite(rx) || !finite(ry) || rx < 0 || ry < 0 || !finite(arc.Rotation) ||
!finite(start.X) || !finite(start.Y) || !finite(arc.End.X) || !finite(arc.End.Y) {
return nil, fmt.Errorf("invalid arc parameters")
a, err := centerArc(start, arc)
if err != nil {
return nil, err
}
if start == arc.End {
return nil, nil
}
if rx == 0 || ry == 0 {
if arc.RadiusX == 0 || arc.RadiusY == 0 {
return GeometryPath{{Verb: GeometryLine, End: arc.End}}, nil
}
radius := math.Max(math.Hypot(a.u.X, a.u.Y), math.Hypot(a.v.X, a.v.Y))
step := math.Min(math.Pi/2, math.Pow(tolerance/radius*40000, 1.0/6))
n := math.Ceil(math.Abs(a.delta) / step)
if !finite(n) || n < 1 || n > 1<<18 {
return nil, fmt.Errorf("arc subdivision limit exceeded")
}
count := int(n)
step = a.delta / float64(count)
point := func(x, y float64) Point {
return Point{a.center.X + a.u.X*x + a.v.X*y, a.center.Y + a.u.Y*x + a.v.Y*y}
}
result := make(GeometryPath, 0, count)
for i := 0; i < count; i++ {
sa, ca := math.Sincos(a.theta + float64(i)*step)
sb, cb := math.Sincos(a.theta + float64(i+1)*step)
k := 4.0 / 3 * math.Tan(step/4)
result = append(result, GeometrySegment{Verb: GeometryCubic, Control1: point(ca-k*sa, sa+k*ca), Control2: point(cb+k*sb, sb-k*cb), End: point(cb, sb)})
}
result[len(result)-1].End = arc.End
return result, nil
}
// centerArc 将端点式圆弧解析为中心式,统一半径修正和方向语义
// 入参: start 起点, arc 弧线
// 返回: geometryArc 中心式参数, error 无效参数或数值溢出
func centerArc(start Point, arc GeometrySegment) (geometryArc, error) {
rx, ry := arc.RadiusX, arc.RadiusY
if !finite(rx) || !finite(ry) || rx < 0 || ry < 0 || !finite(arc.Rotation) ||
!finite(start.X) || !finite(start.Y) || !finite(arc.End.X) || !finite(arc.End.Y) {
return geometryArc{}, fmt.Errorf("invalid arc parameters")
}
if start == arc.End {
return geometryArc{}, nil
}
if rx == 0 || ry == 0 {
return geometryArc{}, nil
}
angle := math.Mod(arc.Rotation, 360) * math.Pi / 180
sine, cosine := math.Sincos(angle)
dx, dy := (start.X-arc.End.X)/2, (start.Y-arc.End.Y)/2
@@ -95,23 +140,29 @@ func arcCurves(start Point, arc GeometrySegment, tolerance float64) (GeometryPat
delta -= 2 * math.Pi
}
center := Point{cosine*cx - sine*cy + (start.X+arc.End.X)/2, sine*cx + cosine*cy + (start.Y+arc.End.Y)/2}
step := math.Min(math.Pi/2, math.Pow(tolerance/math.Max(rx, ry)*40000, 1.0/6))
count := int(math.Ceil(math.Abs(delta) / step))
if count < 1 || !finite(center.X) || !finite(center.Y) {
return nil, fmt.Errorf("invalid arc extent")
if !finite(center.X) || !finite(center.Y) || !finite(rx) || !finite(ry) || !finite(delta) || !finite(theta) || delta == 0 {
return geometryArc{}, fmt.Errorf("invalid arc extent")
}
step = delta / float64(count)
point := func(x, y float64) Point {
return Point{center.X + rx*cosine*x - ry*sine*y, center.Y + rx*sine*x + ry*cosine*y}
return geometryArc{center, Point{rx * cosine, rx * sine}, Point{-ry * sine, ry * cosine}, theta, delta}, nil
}
// point 计算椭圆参数对应的坐标
// 入参: angle 参数角
// 返回: Point 椭圆上的点
func (a geometryArc) point(angle float64) Point {
s, c := math.Sincos(angle)
return Point{a.center.X + a.u.X*c + a.v.X*s, a.center.Y + a.u.Y*c + a.v.Y*s}
}
// fraction 判断参数角是否位于有向弧段内
// 入参: angle 参数角
// 返回: float64 弧段比例
func (a geometryArc) fraction(angle float64) float64 {
d := math.Mod(angle-a.theta, 2*math.Pi)
if a.delta > 0 && d < 0 {
d += 2 * math.Pi
} else if a.delta < 0 && d > 0 {
d -= 2 * math.Pi
}
result := make(GeometryPath, 0, count)
for i := 0; i < count; i++ {
a, b := theta+float64(i)*step, theta+float64(i+1)*step
sa, ca := math.Sincos(a)
sb, cb := math.Sincos(b)
k := 4.0 / 3 * math.Tan(step/4)
result = append(result, GeometrySegment{Verb: GeometryCubic, Control1: point(ca-k*sa, sa+k*ca), Control2: point(cb+k*sb, sb-k*cb), End: point(cb, sb)})
}
result[len(result)-1].End = arc.End
return result, nil
return d / a.delta
}
+32 -25
View File
@@ -26,13 +26,39 @@ const (
GeometryXor
)
// StrokeOptions 描述毫米单位的描边,Cap和Join使用OFD名称
// Tolerance为曲线展开误差,0采用后端默认精度,Dashes和DashOffset为绝对长度
// StrokeOptions 描述描边参数,长度单位为毫米
type StrokeOptions struct {
Width float64
Cap, Join string
MiterLimit, DashOffset, Tolerance float64
Dashes []float64
// Width为正有限描边宽度
Width float64
// Cap和Join使用OFD名称,空值分别采用Butt和Miter
Cap, Join string
// MiterLimit为尖角长度与半线宽的比值上限,0采用默认值
MiterLimit float64
// DashOffset为绝对长度的虚线相位,允许负值
DashOffset float64
// Tolerance为曲线展开误差目标,0采用后端默认精度
Tolerance float64
// Dashes为交替绘制和留空的绝对长度,奇数项按重复一遍处理
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)
}
// GeometryCurves 将椭圆弧转换为光栅后端可消费的贝塞尔曲线,不改变源路径
type GeometryCurves interface {
Curves(path GeometryPath) (GeometryPath, error)
}
// validateStroke 校验各后端共用的描边参数
@@ -56,25 +82,6 @@ func validateStroke(options StrokeOptions) error {
return nil
}
// 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)
}
// GeometryCurves 将椭圆弧转换为光栅后端可消费的贝塞尔曲线,不改变源路径
type GeometryCurves interface {
Curves(path GeometryPath) (GeometryPath, error)
}
// Geometry 返回当前几何后端,不隐式恢复默认实现
// 返回: GeometryBackend 几何后端, error 未配置错误
func (r *Renderer) Geometry() (GeometryBackend, error) {
+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 (
"fmt"
"math"
)
// Bounds 通过解析极值计算直线、贝塞尔和椭圆弧的轴对齐边界
// 不进行曲线展开且不使用Tolerance,结果受float64舍入误差影响,不保证向外舍入
// 返回: Box 浮点路径范围, error 无效路径、弧线或数值溢出
func (p GeometryPath) Bounds() (Box, error) {
if err := p.validate(); err != nil {
return Box{}, err
}
left, top, right, bottom := math.Inf(1), math.Inf(1), math.Inf(-1), math.Inf(-1)
add := func(p Point) {
left, top = math.Min(left, p.X), math.Min(top, p.Y)
right, bottom = math.Max(right, p.X), math.Max(bottom, p.Y)
}
var current, start Point
for _, s := range p {
switch s.Verb {
case GeometryMove:
start = s.End
case GeometryClose:
s.End = start
case GeometryArc:
a, err := centerArc(current, s)
if err != nil {
return Box{}, err
}
if a.delta != 0 {
for _, angle := range []float64{math.Atan2(a.v.X, a.u.X), math.Atan2(a.v.Y, a.u.Y)} {
for _, angle := range []float64{angle, angle + math.Pi} {
if f := a.fraction(angle); f > 0 && f < 1 {
add(a.point(angle))
}
}
}
}
case GeometryQuad, GeometryCubic:
for _, axis := range [][4]float64{{current.X, s.Control1.X, s.Control2.X, s.End.X}, {current.Y, s.Control1.Y, s.Control2.Y, s.End.Y}} {
if s.Verb == GeometryQuad {
axis[2] = 0
}
scale := math.Max(math.Abs(axis[0]), math.Max(math.Abs(axis[1]), math.Max(math.Abs(axis[2]), math.Abs(axis[3]))))
if scale != 0 {
for i := range axis {
axis[i] /= scale
}
}
var roots []float64
if s.Verb == GeometryQuad {
roots = geometryRoots(0, axis[0]-2*axis[1]+axis[3], axis[1]-axis[0])
} else {
roots = geometryRoots(-axis[0]+3*axis[1]-3*axis[2]+axis[3], 2*(axis[0]-2*axis[1]+axis[2]), axis[1]-axis[0])
}
for _, t := range roots {
if t > 0 && t < 1 {
q := geometryLerp(current, s.Control1, t)
if s.Verb == GeometryQuad {
q = geometryLerp(q, geometryLerp(s.Control1, s.End, t), t)
} else {
r := geometryLerp(s.Control1, s.Control2, t)
q = geometryLerp(geometryLerp(q, r, t), geometryLerp(r, geometryLerp(s.Control2, s.End, t), t), t)
}
add(q)
}
}
}
}
add(s.End)
current = s.End
}
if len(p) == 0 {
return Box{}, nil
}
if !finite(left) || !finite(top) || !finite(right-left) || !finite(bottom-top) {
return Box{}, fmt.Errorf("geometry bounds overflow")
}
return Box{left, top, right - left, bottom - top}, nil
}
// geometryRoots 稳定求解一元二次方程的实根
// 入参: a、b、c 方程系数
// 返回: []float64 实根
func geometryRoots(a, b, c float64) []float64 {
scale := math.Max(math.Abs(a), math.Max(math.Abs(b), math.Abs(c)))
if scale == 0 {
return nil
}
a, b, c = a/scale, b/scale, c/scale
if a == 0 {
if b == 0 {
return nil
}
return []float64{-c / b}
}
d := b*b - 4*a*c
if d < 0 {
return nil
}
q := -.5 * (b + math.Copysign(math.Sqrt(d), b))
if q == 0 {
return []float64{-b / (2 * a)}
}
return []float64{q / a, c / q}
}
// geometryLerp 线性插值坐标
// 入参: a、b 端点, t 插值比例
// 返回: Point 插值坐标
func geometryLerp(a, b Point, t float64) Point {
return Point{(1-t)*a.X + t*b.X, (1-t)*a.Y + t*b.Y}
}
+24 -29
View File
@@ -24,6 +24,29 @@ import (
// 坐标以左上角为原点,单位为毫米,子路径从Move开始,Close回到当前子路径起点
type GeometryPath []GeometrySegment
// GeometryVerb 表示几何路径指令
type GeometryVerb uint8
const (
GeometryMove GeometryVerb = iota
GeometryLine
GeometryQuad
GeometryCubic
GeometryArc
GeometryClose
)
// GeometrySegment 保存路径端点、贝塞尔控制点或端点式椭圆弧参数
// 弧线Rotation单位为度,Sweep为true表示顺时针,Large表示大弧
type GeometrySegment struct {
Verb GeometryVerb
End Point
Control1, Control2 Point
RadiusX, RadiusY float64
Rotation float64
Large, Sweep bool
}
// ParseGeometryPath 解析标准OFD紧缩路径,保留弧线和原始数值精度
// 入参: data 紧缩路径
// 返回: GeometryPath 独立路径, error 指令或参数错误
@@ -125,12 +148,7 @@ func closedGeometry(path GeometryPath) GeometryPath {
result = append(result, GeometrySegment{Verb: GeometryClose})
}
result = append(result, segment)
if segment.Verb == GeometryMove {
open = true
}
if segment.Verb == GeometryClose {
open = false
}
open = segment.Verb != GeometryClose
}
if open {
result = append(result, GeometrySegment{Verb: GeometryClose})
@@ -138,29 +156,6 @@ func closedGeometry(path GeometryPath) GeometryPath {
return result
}
// GeometryVerb 表示几何路径指令
type GeometryVerb uint8
const (
GeometryMove GeometryVerb = iota
GeometryLine
GeometryQuad
GeometryCubic
GeometryArc
GeometryClose
)
// GeometrySegment 保存路径端点、贝塞尔控制点或端点式椭圆弧参数
// 弧线Rotation单位为度,Sweep为true表示顺时针,Large表示大弧
type GeometrySegment struct {
Verb GeometryVerb
End Point
Control1, Control2 Point
RadiusX, RadiusY float64
Rotation float64
Large, Sweep bool
}
// SVG 将几何路径编码为SVG路径,不改变曲线或坐标精度
// 返回: string SVG路径, error 不支持的路径指令
func (p GeometryPath) SVG() (string, error) {
+387
View File
@@ -0,0 +1,387 @@
// 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"
)
// geometryPolyline 保存展开后的子路径,独立记录闭合状态
type geometryPolyline struct {
points []Point
closed bool
}
// Stroke 将曲线、正长度虚线及线帽连接转换为非零填充轮廓
// 曲线按Tolerance展开,默认误差为0.001毫米,重叠轮廓保持同向绕数
// 结果未做布尔整理,按非零规则填充,区域运算需能处理重叠子路径的后端
// 入参: options 描边样式
// 返回: GeometryPath 描边轮廓, error 无效参数、不支持的零长度语义或精度限制
func (p GeometryPath) Stroke(options StrokeOptions) (GeometryPath, error) {
if err := validateStroke(options); err != nil {
return nil, err
}
if err := p.validate(); err != nil {
return nil, err
}
if options.Width/2 == 0 {
return nil, fmt.Errorf("stroke width precision exhausted")
}
dashes := append([]float64(nil), options.Dashes...)
total := 0.0
for _, d := range dashes {
total += d
}
if !finite(total) {
return nil, fmt.Errorf("stroke dash period overflow")
}
if total == 0 {
dashes = nil
} else {
for _, d := range dashes {
if d == 0 {
return nil, fmt.Errorf("zero-length stroke dash: %w", ErrBackendUnavailable)
}
}
if len(dashes)%2 != 0 {
dashes = append(dashes, dashes...)
total *= 2
if !finite(total) {
return nil, fmt.Errorf("stroke dash period overflow")
}
}
}
tolerance := options.Tolerance
if tolerance == 0 {
tolerance = .001
}
lines, err := p.strokePolylines(tolerance / 2)
if err != nil {
return nil, err
}
var result GeometryPath
for _, line := range lines {
parts := []geometryPolyline{line}
if len(dashes) != 0 {
parts, err = line.dash(dashes, total, options.DashOffset)
if err != nil {
return nil, err
}
}
for _, part := range parts {
outline, err := part.stroke(options)
if err != nil {
return nil, err
}
result = append(result, outline...)
if len(result) > 1<<20 {
return nil, fmt.Errorf("stroke outline limit exceeded")
}
}
}
if err := result.validate(); err != nil {
return nil, err
}
return result, nil
}
// strokePolylines 自适应展开贝塞尔,弦长亏损同时约束虚线累计长度误差
// 入参: tolerance 展开误差
// 返回: []geometryPolyline 子路径, error 展开限制
func (p GeometryPath) strokePolylines(tolerance float64) ([]geometryPolyline, error) {
curves, err := p.Curves(tolerance / 2)
if err != nil {
return nil, err
}
var result []geometryPolyline
var line geometryPolyline
var current, start Point
drawn := false
flush := func() {
if drawn {
result = append(result, line)
}
line, drawn = geometryPolyline{}, false
}
count := 0
var flatten func([]Point, float64, int) error
flatten = func(points []Point, budget float64, depth int) error {
first, last := points[0], points[len(points)-1]
chord := math.Hypot(last.X-first.X, last.Y-first.Y)
length, distance := 0.0, 0.0
for i := 1; i < len(points); i++ {
length += math.Hypot(points[i].X-points[i-1].X, points[i].Y-points[i-1].Y)
if i == len(points)-1 {
continue
}
if chord == 0 {
distance = math.Max(distance, math.Hypot(points[i].X-first.X, points[i].Y-first.Y))
} else {
distance = math.Max(distance, math.Abs((points[i].X-first.X)*((last.Y-first.Y)/chord)-(points[i].Y-first.Y)*((last.X-first.X)/chord)))
}
}
if !finite(length) || !finite(distance) {
return fmt.Errorf("stroke curve overflow")
}
if distance <= tolerance && length-chord <= budget {
if last != line.points[len(line.points)-1] {
line.points = append(line.points, last)
count++
}
if count > 1<<18 {
return fmt.Errorf("stroke subdivision limit exceeded")
}
return nil
}
if depth == 24 {
return fmt.Errorf("stroke curve tolerance cannot be met")
}
work := append([]Point(nil), points...)
left, right := make([]Point, len(points)), make([]Point, len(points))
for n := len(points); n > 0; n-- {
left[len(points)-n], right[n-1] = work[0], work[n-1]
for i := 0; i < n-1; i++ {
work[i] = geometryLerp(work[i], work[i+1], .5)
}
}
if err := flatten(left, budget/2, depth+1); err != nil {
return err
}
return flatten(right, budget/2, depth+1)
}
for _, s := range curves {
switch s.Verb {
case GeometryMove:
flush()
current, start = s.End, s.End
line.points = []Point{current}
case GeometryClose:
if drawn {
if current != start {
line.points = append(line.points, start)
}
line.closed = true
}
flush()
current = start
line.points = []Point{current}
default:
drawn = true
points := []Point{current}
if s.Verb == GeometryQuad || s.Verb == GeometryCubic {
points = append(points, s.Control1)
}
if s.Verb == GeometryCubic {
points = append(points, s.Control2)
}
points = append(points, s.End)
if err := flatten(points, tolerance/float64(len(curves)), 0); err != nil {
return nil, err
}
current = s.End
}
}
flush()
return result, nil
}
// dash 按子路径重置相位并合并跨闭合接缝的连续虚线
// 入参: pattern 正长度偶数虚线数组, total 周期, offset 偏移
// 返回: []geometryPolyline 虚线段, error 长度或细分限制
func (line geometryPolyline) dash(pattern []float64, total, offset float64) ([]geometryPolyline, error) {
phase := math.Mod(offset, total)
if phase < 0 {
phase += total
}
index := 0
for phase >= pattern[index] {
phase -= pattern[index]
index = (index + 1) % len(pattern)
}
if len(line.points) == 1 {
if index%2 == 0 {
return []geometryPolyline{line}, nil
}
return nil, nil
}
remaining := pattern[index] - phase
var result []geometryPolyline
var run []Point
flush := func() {
if len(run) != 0 {
result = append(result, geometryPolyline{points: run})
run = nil
}
}
steps := 0
for i := 1; i < len(line.points); i++ {
a, b := line.points[i-1], line.points[i]
length := math.Hypot(b.X-a.X, b.Y-a.Y)
if !finite(length) {
return nil, fmt.Errorf("stroke dash length overflow")
}
for at := 0.0; at < length; {
step := math.Min(remaining, length-at)
if at+step == at || steps > 1<<18 {
return nil, fmt.Errorf("stroke dash subdivision limit exceeded")
}
steps++
if index%2 == 0 {
if len(run) == 0 {
run = append(run, geometryLerp(a, b, at/length))
}
run = append(run, geometryLerp(a, b, (at+step)/length))
}
at += step
remaining -= step
if remaining == 0 {
flush()
index = (index + 1) % len(pattern)
remaining = pattern[index]
}
}
}
flush()
if line.closed && len(result) > 0 {
first, last := &result[0], &result[len(result)-1]
if first.points[0] == line.points[0] && last.points[len(last.points)-1] == line.points[0] {
if len(result) == 1 {
first.closed = true
} else {
first.points = append(last.points[:len(last.points)-1], first.points...)
result = result[:len(result)-1]
}
}
}
return result, nil
}
// stroke 用同向矩形、连接楔形及圆盘组成非零填充区域
// 入参: options 描边样式
// 返回: GeometryPath 填充轮廓, error 不支持的退化方形线帽
func (line geometryPolyline) stroke(options StrokeOptions) (GeometryPath, error) {
var result GeometryPath
half := options.Width / 2
polygon := func(points ...Point) {
scale := 0.0
for _, p := range points {
scale = math.Max(scale, math.Max(math.Abs(p.X-points[0].X), math.Abs(p.Y-points[0].Y)))
}
if scale == 0 {
return
}
area := 0.0
for i, p := range points {
q := points[(i+1)%len(points)]
area += ((p.X-points[0].X)/scale)*((q.Y-points[0].Y)/scale) - ((p.Y-points[0].Y)/scale)*((q.X-points[0].X)/scale)
}
if area == 0 {
return
}
if area < 0 {
for i, j := 0, len(points)-1; i < j; i, j = i+1, j-1 {
points[i], points[j] = points[j], points[i]
}
}
result = append(result, GeometrySegment{Verb: GeometryMove, End: points[0]})
for _, p := range points[1:] {
result = append(result, GeometrySegment{Verb: GeometryLine, End: p})
}
result = append(result, GeometrySegment{Verb: GeometryClose})
}
disk := func(p Point) {
result = append(result, GeometrySegment{Verb: GeometryMove, End: Point{p.X + half, p.Y}},
GeometrySegment{Verb: GeometryArc, RadiusX: half, RadiusY: half, Sweep: true, End: Point{p.X - half, p.Y}},
GeometrySegment{Verb: GeometryArc, RadiusX: half, RadiusY: half, Sweep: true, End: Point{p.X + half, p.Y}},
GeometrySegment{Verb: GeometryClose})
}
points := line.points
if len(points) == 1 {
if line.closed {
return nil, fmt.Errorf("directionless closed stroke: %w", ErrBackendUnavailable)
}
if options.Cap == "Round" {
disk(points[0])
} else if options.Cap == "Square" {
return nil, fmt.Errorf("directionless square stroke cap: %w", ErrBackendUnavailable)
}
return result, nil
}
directions := make([]Point, len(points)-1)
for i := range directions {
a, b := points[i], points[i+1]
length := math.Hypot(b.X-a.X, b.Y-a.Y)
if length == 0 || !finite(length) {
return nil, fmt.Errorf("invalid stroke segment length")
}
d := Point{(b.X - a.X) / length, (b.Y - a.Y) / length}
directions[i] = d
n := Point{-half * d.Y, half * d.X}
polygon(Point{a.X + n.X, a.Y + n.Y}, Point{b.X + n.X, b.Y + n.Y}, Point{b.X - n.X, b.Y - n.Y}, Point{a.X - n.X, a.Y - n.Y})
}
join := func(p, a, b Point) {
cross := a.X*b.Y - a.Y*b.X
if cross == 0 {
if options.Join == "Round" && a.X*b.X+a.Y*b.Y < 0 {
disk(p)
}
return
}
side := -math.Copysign(half, cross)
x := Point{p.X - side*a.Y, p.Y + side*a.X}
y := Point{p.X - side*b.Y, p.Y + side*b.X}
if options.Join == "Round" {
if cross < 0 {
x, y = y, x
}
result = append(result, GeometrySegment{Verb: GeometryMove, End: p},
GeometrySegment{Verb: GeometryLine, End: x},
GeometrySegment{Verb: GeometryArc, RadiusX: half, RadiusY: half, Sweep: true, End: y},
GeometrySegment{Verb: GeometryClose})
return
}
limit := options.MiterLimit
if limit == 0 {
limit = defaultMiterLimit
}
if options.Join == "" || options.Join == "Miter" {
t := ((y.X-x.X)*b.Y - (y.Y-x.Y)*b.X) / cross
m := Point{x.X + t*a.X, x.Y + t*a.Y}
if math.Hypot(m.X-p.X, m.Y-p.Y) <= half*limit {
polygon(p, x, m, y)
return
}
}
polygon(p, x, y)
}
for i := 1; i < len(points)-1; i++ {
join(points[i], directions[i-1], directions[i])
}
if line.closed {
join(points[0], directions[len(directions)-1], directions[0])
} else {
for _, end := range []struct{ p, d Point }{{points[0], Point{-directions[0].X, -directions[0].Y}}, {points[len(points)-1], directions[len(directions)-1]}} {
if options.Cap == "Round" {
disk(end.p)
} else if options.Cap == "Square" {
p, d := end.p, end.d
n := Point{-half * d.Y, half * d.X}
polygon(Point{p.X + n.X, p.Y + n.Y}, Point{p.X + n.X + half*d.X, p.Y + n.Y + half*d.Y}, Point{p.X - n.X + half*d.X, p.Y - n.Y + half*d.Y}, Point{p.X - n.X, p.Y - n.Y})
}
}
}
return result, nil
}
+160
View File
@@ -0,0 +1,160 @@
// 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"
)
// validate 校验公共路径指令、子路径起点和有限坐标
// 返回: error 无效路径
func (p GeometryPath) validate() error {
started := false
for _, s := range p {
points := []Point{s.End}
switch s.Verb {
case GeometryMove:
started = true
case GeometryLine:
case GeometryQuad:
points = append(points, s.Control1)
case GeometryCubic:
points = append(points, s.Control1, s.Control2)
case GeometryArc:
if !finite(s.RadiusX) || !finite(s.RadiusY) || !finite(s.Rotation) || s.RadiusX < 0 || s.RadiusY < 0 {
return fmt.Errorf("invalid arc parameters")
}
case GeometryClose:
points = nil
default:
return fmt.Errorf("unsupported geometry command %d", s.Verb)
}
if !started {
return fmt.Errorf("geometry path must start with move")
}
for _, point := range points {
if !finite(point.X) || !finite(point.Y) {
return fmt.Errorf("non-finite geometry coordinate")
}
}
}
return nil
}
// Transform 精确变换控制点和椭圆轴,退化弧沿投影极值分段保留折返
// 入参: matrix 仿射变换
// 返回: GeometryPath 独立路径, error 无效路径或数值溢出
func (p GeometryPath) Transform(matrix Matrix) (GeometryPath, error) {
if err := p.validate(); err != nil {
return nil, err
}
for _, value := range matrix.Values() {
if !finite(value) {
return nil, fmt.Errorf("invalid geometry transform")
}
}
matrixScale := math.Max(math.Max(math.Abs(matrix.a), math.Abs(matrix.b)), math.Max(math.Abs(matrix.c), math.Abs(matrix.d)))
determinant := 0.0
if matrixScale != 0 {
determinant = (matrix.a/matrixScale)*(matrix.d/matrixScale) - (matrix.b/matrixScale)*(matrix.c/matrixScale)
}
transform := func(p Point) Point {
x, y := matrix.Transform(p.X, p.Y)
return Point{x, y}
}
var result GeometryPath
var current, start Point
for _, s := range p {
originalSegment := s
original := s.End
s.End = transform(s.End)
switch s.Verb {
case GeometryMove:
start = original
case GeometryQuad:
s.Control1 = transform(s.Control1)
case GeometryCubic:
s.Control1, s.Control2 = transform(s.Control1), transform(s.Control2)
case GeometryClose:
original, s.End = start, transform(start)
case GeometryArc:
a, err := centerArc(current, originalSegment)
if err != nil {
return nil, err
}
if current == original {
current = original
continue
}
if s.RadiusX == 0 || s.RadiusY == 0 {
s = GeometrySegment{Verb: GeometryLine, End: s.End}
} else {
a.center = transform(a.center)
a.u.X, a.u.Y = matrixVector(matrix, a.u.X, a.u.Y)
a.v.X, a.v.Y = matrixVector(matrix, a.v.X, a.v.Y)
scale := math.Max(math.Hypot(a.u.X, a.u.Y), math.Hypot(a.v.X, a.v.Y))
if !finite(scale) {
return nil, fmt.Errorf("arc transform overflow")
}
if scale == 0 {
if matrixScale != 0 {
return nil, fmt.Errorf("arc transform precision exhausted")
}
s = GeometrySegment{Verb: GeometryLine, End: s.End}
} else {
ux, uy, vx, vy := a.u.X/scale, a.u.Y/scale, a.v.X/scale, a.v.Y/scale
det := ux*vy - uy*vx
if determinant != 0 && det == 0 {
return nil, fmt.Errorf("arc transform precision exhausted")
}
if determinant == 0 {
fractions := []float64{}
for _, angle := range []float64{math.Atan2(a.v.X, a.u.X), math.Atan2(a.v.Y, a.u.Y)} {
for _, angle := range []float64{angle, angle + math.Pi} {
if f := a.fraction(angle); f > 0 && f < 1 {
fractions = append(fractions, f)
}
}
}
slices.Sort(fractions)
for _, f := range slices.Compact(fractions) {
result = append(result, GeometrySegment{Verb: GeometryLine, End: a.point(a.theta + f*a.delta)})
}
s = GeometrySegment{Verb: GeometryLine, End: s.End}
} else {
xx, yy, xy := ux*ux+vx*vx, uy*uy+vy*vy, ux*uy+vx*vy
radius := math.Sqrt((xx + yy + math.Hypot(xx-yy, 2*xy)) / 2)
s.RadiusX, s.RadiusY = scale*radius, scale*math.Abs(det)/radius
if s.RadiusY == 0 {
return nil, fmt.Errorf("arc transform precision exhausted")
}
s.Rotation = math.Atan2(2*xy, xx-yy) * 90 / math.Pi
if det < 0 {
s.Sweep = !s.Sweep
}
}
}
}
}
result = append(result, s)
current = original
}
if err := result.validate(); err != nil {
return nil, err
}
return result, nil
}
+161
View File
@@ -0,0 +1,161 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
import (
"bytes"
"path"
"slices"
"strconv"
"strings"
)
// packageDirectory 获取当前文档的包内根目录,新建文档与编辑文档共用布局
// 返回: string 文档目录
func (e *Editor) packageDirectory() string {
if e.source != nil {
return e.source.directory
}
return "Doc_0"
}
// packagePagePath 按稳定页面标识生成路径,页面重排不改变文件名
// 入参: directory 文档目录, id 页面标识
// 返回: string 页面路径
func packagePagePath(directory, id string) string {
return path.Join(directory, "Pages", "Page_"+id, "Content.xml")
}
// packageOFDNode 判断包索引中的标准节点,兼容旧版及省略命名空间的文档
// 入参: node XML节点, name 节点名称
// 返回: bool 是否为标准节点
func packageOFDNode(node *editorXML, name string) bool {
return node.name.Local == name && (node.name.Space == "" || node.name.Space == ofdNamespace || node.name.Space == "http://www.ofdspec.org")
}
// packageName 为新增条目分配文档内路径,不覆盖原文件或本次资源
// 入参: relative 文档相对路径
// 返回: string 未占用路径
func (e *Editor) packageName(relative string) string {
var reader *Reader
if e.source != nil {
reader = e.source.reader
}
files := make(map[string][]byte, len(e.resources))
for _, resource := range e.resources {
files[resource.name] = nil
}
return packageAvailableName(reader, files, path.Join(e.packageDirectory(), relative))
}
// packageAvailableName 仅在包内同名条目冲突时添加序号,忽略大小写差异
// 入参: reader 原包,可为nil, files 待写入条目, name 期望路径
// 返回: string 未占用路径
func packageAvailableName(reader *Reader, files map[string][]byte, name string) string {
base, extension := strings.TrimSuffix(name, path.Ext(name)), path.Ext(name)
for i := 0; ; i++ {
candidate := name
if i != 0 {
candidate = base + "_" + strconv.Itoa(i) + extension
}
occupied := false
if reader != nil {
_, occupied = reader.packageFile(candidate)
for file := range reader.files {
occupied = occupied || strings.EqualFold(cleanPackagePath(file), candidate)
}
}
for file := range files {
occupied = occupied || strings.EqualFold(cleanPackagePath(file), candidate)
}
if !occupied {
return candidate
}
}
}
// mergePackageResources 合并新增资源定义,保留原索引、路径基准与未知字段
// 入参: reader 原包, parts 待写入条目, doc 文档, directory 文档目录, added 新增资源XML,文件引用使用绝对包路径
// 返回: string 资源索引路径, bool 是否需要新增文档引用, error 错误信息
func mergePackageResources(reader *Reader, parts map[string][]byte, doc *Document, directory string, added []byte) (string, bool, error) {
if len(doc.CommonData.DocumentRes) == 0 {
name := packageAvailableName(reader, parts, path.Join(directory, "DocumentRes.xml"))
parts[name] = added
return name, true, nil
}
name := reader.ResPath(doc.CommonData.DocumentRes[0])
if file, ok := reader.packageFile(name); ok {
name = cleanPackagePath(file.Name)
}
data, ok := parts[name]
if !ok {
var err error
data, err = reader.readFile(name)
if err != nil {
return "", false, err
}
}
addition, err := parseEditorXML(added)
if err != nil {
return "", false, err
}
order := []string{"ColorSpaces", "DrawParams", "Fonts", "MultiMedias", "CompositeGraphicUnits"}
for _, group := range addition.children {
root, err := parseEditorXML(data)
if err != nil {
return "", false, err
}
target := root.child(group.name.Local)
var content []byte
for _, node := range group.children {
if target != nil && slices.ContainsFunc(target.children, func(old *editorXML) bool {
return packageOFDNode(old, node.name.Local) && old.attr("ID") == node.attr("ID")
}) {
continue
}
entry, err := editorXMLStandalone(added[node.start:node.end], node)
if err != nil {
return "", false, err
}
content = append(content, entry...)
}
if len(content) == 0 {
continue
}
if target != nil {
content = append(bytes.Clone(data[target.open:target.close]), content...)
data = editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, target, content)})
continue
}
content, err = editorXMLContainer(group.name.Local, nil, content)
if err != nil {
return "", false, err
}
position := root.close
for _, child := range root.children {
if packageOFDNode(child, child.name.Local) && slices.Index(order, child.name.Local) > slices.Index(order, group.name.Local) {
position = child.start
break
}
}
if root.open == root.end {
data = editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, root, content)})
} else {
data = editorPatchXML(data, []editorXMLPatch{{position, position, content}})
}
}
parts[name] = data
return name, false, nil
}
+1 -1
View File
@@ -16,7 +16,7 @@ package ofdgo
import "image/color"
// ColorStop 保存预乘RGBA颜色节点及渐变区间位置
// ColorStop 保存渐变区间位置及预乘RGBA颜色节点
type ColorStop struct {
Offset float64
Color color.RGBA
+2 -2
View File
@@ -55,10 +55,10 @@ type RasterSegment struct {
}
// RasterCommand 填充路径或绘制图像,Image非空时为图像指令
// Transform将局部坐标映射到页面,图像局部坐标与image.Image的像素坐标一致
// Stroke非空时为描边,宽度与虚线使用页面毫米,不随Transform再次缩放
// EvenOdd仅控制填充规则,不影响描边
// Transform将局部坐标映射到页面,图像局部坐标与image.Image的像素坐标一致
// Clip为页面坐标裁剪,适用于路径和图像,nil表示不裁剪,空切片表示全部裁去
// Stroke非空时为描边,宽度与虚线使用页面毫米,不随Transform再次缩放
type RasterCommand struct {
Path []RasterSegment
Paint RasterPaint
+39 -1
View File
@@ -32,7 +32,32 @@ type RasterSceneBackend interface {
Prepare(page *RasterPage) (RasterScene, error)
}
// PreparePage 编译并准备当前配置的页面场景,不自动跟踪后续编辑
// RasterSceneUpdater 可选的增量场景能力,保持原后端与显式几何配置
// Update按新页面的完整内容及DPI使派生数据失效,失败时原场景仍可绘制
// 图片仍为只读借用,编辑图片应提供新资源;修改后端配置应重新Prepare
type RasterSceneUpdater interface {
RasterScene
Update(page *RasterPage) error
}
// RasterSceneReuseBackend 可选的跨快照复用能力,不改变显式后端选择
// 实现必须验证前次场景及配置,返回独立快照,不能修改仍可使用的旧场景
type RasterSceneReuseBackend interface {
RasterSceneBackend
PrepareReuse(page *RasterPage, previous RasterScene) (RasterScene, error)
}
// rasterPreparationKey 隔离渲染器私有的前次场景
type rasterPreparationKey struct{}
// rasterPreparationState 记录前次场景及实际后端配置
type rasterPreparationState struct {
backend RasterBackend
scene RasterScene
}
// PreparePage 编译并准备当前配置的独立页面快照,按后端能力复用未修改指令
// 每次重新解释源页面,按内容使模板、签章、字体及样式派生数据失效
// 入参: page 源页面
// 返回: RasterScene 可重复绘制场景, error 编译或准备错误
func (r *Renderer) PreparePage(page *PageContent) (RasterScene, error) {
@@ -43,6 +68,19 @@ func (r *Renderer) PreparePage(page *PageContent) (RasterScene, error) {
if err != nil {
return nil, err
}
if backend, ok := r.backends.Raster.(RasterSceneReuseBackend); ok {
state, _ := r.backendStates[rasterPreparationKey{}].(rasterPreparationState)
var previous RasterScene
if sameBackend(state.backend, r.backends.Raster) {
previous = state.scene
}
scene, err := backend.PrepareReuse(compiled, previous)
if err != nil {
return nil, err
}
r.backendStates[rasterPreparationKey{}] = rasterPreparationState{backend: r.backends.Raster, scene: scene}
return scene, nil
}
return PrepareRasterScene(r.backends.Raster, compiled)
}
+87 -1
View File
@@ -14,7 +14,12 @@
package ofdgo
import "container/list"
import (
"container/list"
"crypto/sha256"
"encoding/binary"
"math"
)
// renderCache 按估算字节数限制派生渲染数据,淘汰不修改源资源
type renderCache[K comparable, V any] struct {
@@ -66,3 +71,84 @@ func (c *renderCache[K, V]) put(key K, value V, cost int) {
c.entries[key] = c.order.PushFront(renderCacheEntry[K, V]{key, value, cost})
c.used += cost
}
// rasterCommandCost 估算指令快照及后端路径的缓存成本,不含借用图片
// 入参: command 绘制指令
// 返回: int 字节成本
func rasterCommandCost(command RasterCommand) int {
cost := 512 + (len(command.Path)+len(command.Clip))*208
if command.Stroke != nil {
cost += 128 + len(command.Stroke.Dashes)*16
}
if command.Paint.Gradient != nil {
cost += 256 + len(command.Paint.Gradient.Stops)*64
}
return cost
}
// rasterCommandKey 按完整路径、样式、裁剪及像素配置标识非图片指令
// 入参: page 页面尺寸和DPI, command 绘制指令
// 返回: [32]byte 内容键
func rasterCommandKey(page *RasterPage, command RasterCommand) [32]byte {
h := sha256.New()
path, clip := rasterClipKey(page, command.Path), rasterClipKey(page, command.Clip)
h.Write(path[:])
h.Write(clip[:])
var data [8]byte
number := func(value float64) {
binary.LittleEndian.PutUint64(data[:], math.Float64bits(value))
h.Write(data[:])
}
flag := func(value bool) {
if value {
h.Write([]byte{1})
} else {
h.Write([]byte{0})
}
}
word := func(value string) {
number(float64(len(value)))
h.Write([]byte(value))
}
flag(command.Clip != nil)
flag(command.EvenOdd)
for _, value := range command.Transform {
number(value)
}
c := command.Paint.Color
h.Write([]byte{c.R, c.G, c.B, c.A})
flag(command.Stroke != nil)
if stroke := command.Stroke; stroke != nil {
number(stroke.Width)
number(stroke.MiterLimit)
number(stroke.Tolerance)
number(stroke.DashOffset)
word(stroke.Cap)
word(stroke.Join)
number(float64(len(stroke.Dashes)))
for _, dash := range stroke.Dashes {
number(dash)
}
}
flag(command.Paint.Gradient != nil)
if g := command.Paint.Gradient; g != nil {
h.Write([]byte{byte(g.Kind)})
for _, value := range []float64{g.Start.X, g.Start.Y, g.End.X, g.End.Y, g.R0, g.R1} {
number(value)
}
flag(g.Spread != nil)
if spread := g.Spread; spread != nil {
number(float64(spread.Extend))
number(spread.Period)
word(spread.MapType)
}
for _, stop := range g.Stops {
number(stop.Offset)
c := stop.Color
h.Write([]byte{c.R, c.G, c.B, c.A})
}
}
var key [32]byte
copy(key[:], h.Sum(nil))
return key
}
+7
View File
@@ -121,6 +121,13 @@ func (c *semanticCompiler) addImage(img image.Image, matrix Matrix, clip *Geomet
// 入参: stamp 印章
// 返回: error 印章解析或编译错误
func (c *semanticCompiler) DrawStamp(stamp Stamp) error {
return c.sharedStamp(stamp, (*semanticCompiler).drawStampContent)
}
// drawStampContent 解释未缓存的签章内容,保持嵌套文档及裁剪语义
// 入参: stamp 印章
// 返回: error 印章解析或编译错误
func (c *semanticCompiler) drawStampContent(stamp Stamp) error {
if c.textOnly || c.measure || len(stamp.Data) == 0 {
return nil
}
+2 -5
View File
@@ -174,14 +174,11 @@ func (r *Renderer) exportProgress(completed, total int) error {
// 入参: page 页面内容
// 返回: image.Image 图像对象, error 错误信息
func (r *Renderer) RenderToImage(page *PageContent) (image.Image, error) {
if r.backends.Raster == nil {
return nil, fmt.Errorf("raster: %w", ErrBackendUnavailable)
}
scene, err := r.CompilePage(page)
scene, err := r.PreparePage(page)
if err != nil {
return nil, err
}
return r.backends.Raster.Render(scene)
return scene.Render()
}
// RenderToPNG 渲染为PNG,像素尺寸由DPI决定
+148 -8
View File
@@ -14,11 +14,86 @@
package ofdgo
// patternCellKey 区分共享底纹、颜色、透明度和实际页面变换
import (
"crypto/sha256"
"slices"
)
// stampContentKey 区分印章内容、物理尺寸、裁剪与字体栅格化精度,不含放置位置
type stampContentKey struct {
data [32]byte
kind string
width, height float64
dpi float64
clip Box
clipped bool
annotations bool
}
// stampContentState 保存有界的共享签章编译结果及配置
type stampContentState struct {
config RenderBackends
cache renderCache[stampContentKey, []RasterCommand]
}
// stampContentStateKey 隔离渲染器私有的共享签章数据
type stampContentStateKey struct{}
// resetFonts 使包含文字的签章编译结果随字体配置失效
func (s *stampContentState) resetFonts() {
s.cache = renderCache[stampContentKey, []RasterCommand]{limit: 16 << 20}
}
// sharedStamp 复用相同签章的解码、嵌套文档编译及局部裁剪,不缓存放置位置
// 入参: stamp 签章, compile 未缓存的签章编译方法
// 返回: error 签章解析或编译错误
func (c *semanticCompiler) sharedStamp(stamp Stamp, compile func(*semanticCompiler, Stamp) error) error {
if c.textOnly || c.measure || len(stamp.Data) == 0 {
return nil
}
r := c.renderer
state, _ := r.backendStates[stampContentStateKey{}].(*stampContentState)
config := r.backends
if state == nil || !sameBackend(state.config.Fonts, config.Fonts) || !sameBackend(state.config.Geometry, config.Geometry) ||
!sameBackend(state.config.Compiler, config.Compiler) || !sameBackend(state.config.Raster, config.Raster) {
state = &stampContentState{config: config}
state.resetFonts()
r.backendStates[stampContentStateKey{}] = state
}
key := stampContentKey{data: sha256.Sum256(stamp.Data), kind: stamp.Type, width: stamp.Box.W, height: stamp.Box.H, dpi: c.page.DPI, clipped: stamp.Clip != nil, annotations: r.RenderAnnotations}
if stamp.Clip != nil {
key.clip = *stamp.Clip
}
commands, ok := state.cache.get(key)
if !ok {
local := stamp
local.Box.X, local.Box.Y = 0, 0
page := &RasterPage{Width: c.page.Width, Height: c.page.Height, DPI: c.page.DPI}
child := &semanticCompiler{renderer: r, geometry: c.geometry, page: page, patterns: c.patterns}
if err := compile(child, local); err != nil {
return err
}
commands = page.Commands
cost := 0
for _, command := range commands {
cost += rasterCommandCost(command)
if command.Image != nil {
bounds := command.Image.Bounds()
cost += bounds.Dx() * bounds.Dy() * 4
}
}
state.cache.put(key, commands, cost)
}
c.page.Commands = append(c.page.Commands, translatePatternCommands(commands, stamp.Box.X, stamp.Box.Y)...)
return nil
}
// patternCellKey 区分共享底纹、颜色、透明度和影响单元语义的页面变换
type patternCellKey struct {
pattern *Pattern
color, space string
index, alpha int
colorAlpha int
matrix [6]float64
}
@@ -26,15 +101,23 @@ type patternCellKey struct {
// 入参: pattern 底纹画刷, matrix 单元到页面矩阵
// 返回: []RasterCommand 只读单元指令, error 编译错误
func (c *semanticCompiler) patternCell(pattern *PatternPaint, matrix Matrix) ([]RasterCommand, error) {
key := patternCellKey{pattern: pattern.Pattern, color: pattern.Color.Value, space: pattern.Color.ColorSpace, index: -1, alpha: 255, matrix: matrix.Values()}
x, y := 0.0, 0.0
if patternCellTranslatable(pattern) {
x, y = matrix.e, matrix.f
matrix.e, matrix.f = 0, 0
}
key := patternCellKey{pattern: pattern.Pattern, color: pattern.Color.Value, space: pattern.Color.ColorSpace, index: -1, alpha: 255, colorAlpha: 255, matrix: matrix.Values()}
if pattern.Color.Index != nil {
key.index = *pattern.Color.Index
}
if pattern.Alpha != nil {
key.alpha = *pattern.Alpha
}
if pattern.Color.Alpha != nil {
key.colorAlpha = *pattern.Color.Alpha
}
if commands, ok := c.patterns.get(key); ok {
return commands, nil
return translatePatternCommands(commands, x, y), nil
}
child := &semanticCompiler{renderer: c.renderer, geometry: c.geometry, page: &RasterPage{Width: c.page.Width, Height: c.page.Height, DPI: c.page.DPI}, pattern: true, patterns: c.patterns}
defaults := &DrawParam{FillColor: &pattern.Color, StrokeColor: (*StrokeColor)(&pattern.Color)}
@@ -46,13 +129,70 @@ func (c *semanticCompiler) patternCell(pattern *PatternPaint, matrix Matrix) ([]
}
cost := 0
for _, cmd := range child.page.Commands {
cost += 256 + (len(cmd.Path)+len(cmd.Clip))*104
if cmd.Paint.Gradient != nil {
cost += 128 + len(cmd.Paint.Gradient.Stops)*16
}
cost += rasterCommandCost(cmd)
}
c.patterns.put(key, child.page.Commands, cost)
return child.page.Commands, nil
return translatePatternCommands(child.page.Commands, x, y), nil
}
// patternCellTranslatable 检查无需按页面位置重新解释的叶子单元
// 入参: pattern 底纹画刷
// 返回: bool 是否可仅平移编译结果
func patternCellTranslatable(pattern *PatternPaint) bool {
if pattern.Color.Pattern != nil {
return false
}
for _, object := range pattern.CellContent.Objects {
var fill, stroke *FillColor
switch object.Type {
case "PathObject":
if object.PathObject.DrawParam != "" {
return false
}
fill, stroke = object.PathObject.FillColor, (*FillColor)(object.PathObject.StrokeColor)
case "TextObject":
if object.TextObject.DrawParam != "" {
return false
}
fill, stroke = object.TextObject.FillColor, (*FillColor)(object.TextObject.StrokeColor)
case "ImageObject":
if object.ImageObject.Border != nil {
stroke = (*FillColor)(object.ImageObject.Border.BorderColor)
}
default:
return false
}
if fill != nil && fill.Pattern != nil || stroke != nil && stroke.Pattern != nil {
return false
}
}
return true
}
// translatePatternCommands 平移单元实例,共享只读路径和画刷,独立处理页面裁剪
// 入参: commands 原点单元, x 横向平移, y 纵向平移
// 返回: []RasterCommand 放置后的指令
func translatePatternCommands(commands []RasterCommand, x, y float64) []RasterCommand {
if x == 0 && y == 0 {
return commands
}
result := slices.Clone(commands)
for i := range result {
command := &result[i]
command.Transform[4] += x
command.Transform[5] += y
if command.Clip != nil {
command.Clip = slices.Clone(command.Clip)
matrix := RasterMatrix{1, 0, 0, 1, x, y}
for j := range command.Clip {
segment := &command.Clip[j]
segment.End = matrix.Apply(segment.End)
segment.Control1 = matrix.Apply(segment.Control1)
segment.Control2 = matrix.Apply(segment.Control2)
}
}
}
return result
}
// appendPatternCell 将缓存指令放入当前裁剪,不修改单元数据
+79 -8
View File
@@ -82,6 +82,71 @@ func (r *Renderer) preparedOutline(prepared *PreparedFont, metrics FontMetrics,
return path, err
}
// preparedOutlines 批量提取缓存未命中的字形,保留原始编号顺序,不触发塑形
// 入参: prepared 字体, metrics 度量, glyphs 字形编号, size 毫米字号
// 返回: []GeometryPath 同序只读轮廓, error 能力或解析错误
func (r *Renderer) preparedOutlines(prepared *PreparedFont, metrics FontMetrics, glyphs []uint16, size float64) ([]GeometryPath, error) {
result := make([]GeometryPath, len(glyphs))
var indices []int
missing := make([]uint16, 0)
unique := make(map[uint16]int)
for i, glyph := range glyphs {
if path, ok := r.glyphOutlines.get(glyphOutlineKey{prepared, glyph, size}); ok {
result[i] = path
continue
}
if indices == nil {
indices = make([]int, len(glyphs))
for j := range indices {
indices[j] = -1
}
}
index, ok := unique[glyph]
if !ok {
index = len(missing)
unique[glyph] = index
missing = append(missing, glyph)
}
indices[i] = index
}
if len(missing) == 0 {
return result, nil
}
var paths []GeometryPath
if batch, ok := metrics.(FontOutlineBatch); ok {
var err error
paths, err = batch.GlyphOutlines(missing, size)
if err != nil {
return nil, err
}
if len(paths) != len(missing) {
return nil, fmt.Errorf("font outline batch returned %d paths for %d glyphs", len(paths), len(missing))
}
} else {
provider, ok := metrics.(FontOutlines)
if !ok {
return nil, fmt.Errorf("font outlines: %w", ErrBackendUnavailable)
}
paths = make([]GeometryPath, len(missing))
for i, glyph := range missing {
var err error
paths[i], err = provider.GlyphOutline(glyph, size)
if err != nil {
return nil, err
}
}
}
for i, path := range paths {
r.glyphOutlines.put(glyphOutlineKey{prepared, missing[i], size}, path, len(path)*96+128)
}
for i, index := range indices {
if index >= 0 {
result[i] = paths[index]
}
}
return result, nil
}
// renderObjectMatrix 统一边界、局部变换与父变换的组合顺序
// 入参: boundary 对象边界, local 局部矩阵, state 继承状态
// 返回: Matrix 页面矩阵, Matrix 不含边界平移的组合矩阵
@@ -237,19 +302,25 @@ func (r *Renderer) PositionText(object TextObject, state RenderState) (*Position
spans[i] = [2]int{0, 1}
}
}
positioner := NewTextPositioner(code, object.ReadDirection)
ids := make([]uint16, len(glyphs))
for i, glyph := range glyphs {
if glyph.GlyphID > math.MaxUint16 || glyph.GlyphID < 0 && glyph.Text == "" {
return nil, fmt.Errorf("invalid glyph index %d", glyph.GlyphID)
}
gid := uint16(glyph.GlyphID)
if glyph.GlyphID < 0 {
characters := []rune(glyph.Text)
if len(characters) > 0 {
gid = metrics.GlyphIndex(characters[0])
}
}
path, err := r.preparedOutline(prepared, metrics, gid, size)
if err != nil {
return nil, err
gid = metrics.GlyphIndex(characters[0])
}
ids[i] = gid
}
paths, err := r.preparedOutlines(prepared, metrics, ids, size)
if err != nil {
return nil, err
}
positioner := NewTextPositioner(code, object.ReadDirection)
for i, gid := range ids {
path := paths[i]
width := float64(metrics.GlyphAdvance(gid)) * unit
advance := width * horizontal
if (object.ReadDirection-object.CharDirection)%180 != 0 {
+166 -11
View File
@@ -16,14 +16,34 @@ package ofdgo
import (
"bytes"
"cmp"
"fmt"
"math"
"slices"
"strconv"
"strings"
"sync"
"unicode"
"unicode/utf8"
typefont "github.com/go-text/typesetting/font"
"github.com/go-text/typesetting/harfbuzz"
"github.com/go-text/typesetting/language"
ggtext "github.com/gogpu/gg/text"
xlanguage "golang.org/x/text/language"
)
// ggFontMetrics 适配GG设计单位度量与字形轮廓,按需缓存可配置塑形器
type ggFontMetrics struct {
font ggtext.ParsedFont
data []byte
source *ggtext.FontSource
shaper *ggtext.OwnShaper
shapeMu sync.Mutex
shapeFont *harfbuzz.Font
shapeBuffer *harfbuzz.Buffer
}
// ResolveFont 使用公共字体来源匹配,不创建Canvas字体
// 入参: r 渲染器, id 字体ID, exact 是否禁止无关回退
// 返回: ResolvedFont 字体及来源, error 解析错误
@@ -43,16 +63,8 @@ func (GGBackend) OpenFont(data []byte) (FontMetrics, error) {
return &ggFontMetrics{font: source.Parsed(), data: data, source: source, shaper: ggtext.NewOwnShaper()}, nil
}
// ggFontMetrics 适配GG设计单位度量与字形轮廓
type ggFontMetrics struct {
font ggtext.ParsedFont
data []byte
source *ggtext.FontSource
shaper *ggtext.OwnShaper
}
// ShapeText 使用GG执行横向字偶距与连字塑形,不改动原文定位
// 当前支持拉丁、希腊、西里尔和东亚文字,复杂文字需提供其他FontShaper
// 当前支持拉丁、希腊、西里尔和东亚文字,复杂文字可通过ShapeTextWithOptions指定脚本与方向
// 入参: value 单行原文, size 毫米字号
// 返回: []ShapedGlyph 定位字形, error 字符或能力错误
func (f *ggFontMetrics) ShapeText(value string, size float64) ([]ShapedGlyph, error) {
@@ -60,7 +72,7 @@ func (f *ggFontMetrics) ShapeText(value string, size float64) ([]ShapedGlyph, er
return nil, fmt.Errorf("invalid shaping text or size")
}
for _, char := range value {
if unicode.IsControl(char) {
if unicode.IsControl(char) || char == '\u2028' || char == '\u2029' {
return nil, fmt.Errorf("shaping requires a single text line")
}
if !unicode.In(char, unicode.Latin, unicode.Greek, unicode.Cyrillic, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul, unicode.Common, unicode.Inherited) {
@@ -75,6 +87,107 @@ func (f *ggFontMetrics) ShapeText(value string, size float64) ([]ShapedGlyph, er
return result, nil
}
// ShapeTextWithOptions 使用现有纯Go塑形引擎处理单一方向原文,保留逻辑簇和视觉位置
// 不执行混合方向分段,调用方应将双向段落拆为单向文字对象
// 入参: value 单行原文, size 毫米字号, options 塑形选项
// 返回: []ShapedGlyph 定位字形, error 字符或选项错误
func (f *ggFontMetrics) ShapeTextWithOptions(value string, size float64, options TextShapeOptions) ([]ShapedGlyph, error) {
if options == (TextShapeOptions{}) {
return f.ShapeText(value, size)
}
if !rasterPositive(size) || !utf8.ValidString(value) {
return nil, fmt.Errorf("invalid shaping text or size")
}
for _, char := range value {
if unicode.IsControl(char) || char == '\u2028' || char == '\u2029' {
return nil, fmt.Errorf("shaping requires a single text line")
}
}
props := harfbuzz.SegmentProperties{Direction: harfbuzz.LeftToRight}
switch options.Direction {
case "", "ltr":
case "rtl":
props.Direction = harfbuzz.RightToLeft
default:
return nil, fmt.Errorf("shaping direction %q: %w", options.Direction, ErrBackendUnavailable)
}
if options.Script != "" {
if len(options.Script) != 4 || strings.ContainsFunc(options.Script, func(c rune) bool { return c < 'A' || c > 'Z' && c < 'a' || c > 'z' }) {
return nil, fmt.Errorf("invalid shaping script %q", options.Script)
}
props.Script, _ = language.ParseScript(options.Script)
}
if options.Language != "" {
tag, err := xlanguage.Parse(options.Language)
if err != nil {
return nil, fmt.Errorf("shaping language: %w", err)
}
props.Language = language.NewLanguage(tag.String())
}
var features []harfbuzz.Feature
if options.Features != "" {
for _, setting := range strings.Split(options.Features, ",") {
setting = strings.TrimSpace(setting)
if strings.ContainsAny(setting, "[]") {
return nil, fmt.Errorf("shaping feature ranges: %w", ErrBackendUnavailable)
}
tag, value, hasValue := strings.Cut(setting, "=")
if len(tag) != 4 || strings.ContainsFunc(tag, func(c rune) bool { return c < '0' || c > '9' && c < 'A' || c > 'Z' && c < 'a' || c > 'z' }) {
return nil, fmt.Errorf("invalid shaping feature %q", setting)
}
if hasValue {
if _, err := strconv.ParseUint(value, 10, 32); err != nil {
return nil, fmt.Errorf("shaping feature %q: %w", setting, err)
}
}
feature, err := harfbuzz.ParseFeature(setting)
if err != nil {
return nil, fmt.Errorf("shaping feature %q: %w", setting, err)
}
if feature.Start != harfbuzz.FeatureGlobalStart || feature.End != harfbuzz.FeatureGlobalEnd {
return nil, fmt.Errorf("shaping feature ranges: %w", ErrBackendUnavailable)
}
features = append(features, feature)
}
}
f.shapeMu.Lock()
defer f.shapeMu.Unlock()
if f.shapeFont == nil {
face, err := typefont.ParseTTF(bytes.NewReader(f.data))
if err != nil {
return nil, fmt.Errorf("shaping font: %w", err)
}
f.shapeFont = harfbuzz.NewFont(face)
f.shapeBuffer = harfbuzz.NewBuffer()
}
buffer := f.shapeBuffer
buffer.Clear()
buffer.Props = props
runes := []rune(value)
buffer.AddRunes(runes, 0, len(runes))
buffer.GuessSegmentProperties()
buffer.Shape(f.shapeFont, features)
unit := size / float64(f.UnitsPerEm())
result := make([]ShapedGlyph, len(buffer.Info))
var x, y float64
for i, info := range buffer.Info {
if info.Glyph >= typefont.GID(f.NumGlyphs()) {
return nil, fmt.Errorf("invalid shaped glyph index %d", info.Glyph)
}
position := buffer.Pos[i]
result[i] = ShapedGlyph{Glyph: uint16(info.Glyph), Cluster: info.Cluster,
X: (x + float64(position.XOffset)) * unit, Y: -(y + float64(position.YOffset)) * unit,
Advance: float64(position.XAdvance) * unit}
if !finite(result[i].X) || !finite(result[i].Y) || !finite(result[i].Advance) {
return nil, fmt.Errorf("shaped position exceeds finite range")
}
x += float64(position.XAdvance)
y += float64(position.YAdvance)
}
slices.SortStableFunc(result, func(a, b ShapedGlyph) int { return cmp.Compare(a.Cluster, b.Cluster) })
return result, nil
}
// UnitsPerEm 返回字体设计单位
// 返回: uint16 每字单位数
func (f *ggFontMetrics) UnitsPerEm() uint16 { return uint16(f.font.UnitsPerEm()) }
@@ -117,7 +230,49 @@ func (f *ggFontMetrics) GlyphOutline(glyph uint16, size float64) (GeometryPath,
if err != nil || outline == nil {
return nil, err
}
result := make(GeometryPath, 0, len(outline.Segments)+1)
return ggOutlinePath(outline)
}
// GlyphOutlines 按指定编号批量提取轮廓,重复字形只解析一次
// 入参: glyphs 字形编号, size 毫米字号
// 返回: []GeometryPath 同序只读轮廓, error 轮廓解析错误
func (f *ggFontMetrics) GlyphOutlines(glyphs []uint16, size float64) ([]GeometryPath, error) {
if !rasterPositive(size) {
return nil, fmt.Errorf("invalid glyph size")
}
for _, glyph := range glyphs {
if int(glyph) >= f.font.NumGlyphs() {
return nil, fmt.Errorf("invalid glyph index %d", glyph)
}
}
result := make([]GeometryPath, len(glyphs))
paths := make(map[uint16]GeometryPath, len(glyphs))
for i, glyph := range glyphs {
path, ok := paths[glyph]
if !ok {
var err error
path, err = f.GlyphOutline(glyph, size)
if err != nil {
return nil, err
}
paths[glyph] = path
}
result[i] = path
}
return result, nil
}
// ggOutlinePath 将GG轮廓转换为闭合的公共字形路径
// 入参: outline GG字形轮廓
// 返回: GeometryPath 公共路径, error 未知轮廓指令
func ggOutlinePath(outline *ggtext.GlyphOutline) (GeometryPath, error) {
capacity := len(outline.Segments)
for _, segment := range outline.Segments {
if segment.Op == ggtext.OutlineOpMoveTo {
capacity++
}
}
result := make(GeometryPath, 0, capacity)
point := func(p ggtext.OutlinePoint) Point { return Point{X: float64(p.X), Y: float64(p.Y)} }
for _, s := range outline.Segments {
segment := GeometrySegment{End: point(s.Points[0])}
+86 -86
View File
@@ -15,16 +15,15 @@
package ofdgo
import (
"errors"
"fmt"
"github.com/gogpu/gg"
)
// GGGeometryBackend 使用GG处理贝塞尔边界和变换,其他运算由显式配置的几何后端承担
// GeometryBackend不可为空,椭圆弧保持精确表示,不为接入GG而预先离散化
// GGGeometryBackend 使用公共边界、变换和描边,区域运算由显式配置的几何后端承担
// 未配置GeometryBackend时仍可独立处理路径、曲线和描边
type GGGeometryBackend struct{ GeometryBackend }
// Path 解析库自有路径并使用GG几何变换,不经过Canvas路径构建器
// Path 解析库自有路径并使用公共几何变换
// 入参: object 路径对象
// 返回: GeometryPath 页面路径, error 参数或变换错误
func (b GGGeometryBackend) Path(object PathObject) (GeometryPath, error) {
@@ -33,7 +32,12 @@ func (b GGGeometryBackend) Path(object PathObject) (GeometryPath, error) {
// Name 返回实际几何提供者组合
// 返回: string 后端组合标识
func (b GGGeometryBackend) Name() string { return "gg+" + backendName(b.GeometryBackend) }
func (b GGGeometryBackend) Name() string {
if b.GeometryBackend == nil {
return "gg"
}
return "gg+" + backendName(b.GeometryBackend)
}
// Curves 使用公共弧线转换,不调用组合几何后端
// 入参: path 页面路径
@@ -42,97 +46,93 @@ func (b GGGeometryBackend) Curves(path GeometryPath) (GeometryPath, error) {
return path.Curves(.0001)
}
// Bounds 使用GG计算贝塞尔曲线极值,椭圆弧交给配置的几何后端
// Bounds 使用公共解析极值,不依赖组合几何后端
// 入参: path 页面路径
// 返回: Box 精确范围, error 路径或能力错误
// 返回: Box 受浮点舍入影响的解析范围, error 路径或数值错误
func (b GGGeometryBackend) Bounds(path GeometryPath) (Box, error) {
for _, s := range path {
if s.Verb == GeometryArc {
if b.GeometryBackend == nil {
return Box{}, ErrBackendUnavailable
}
return b.GeometryBackend.Bounds(path)
}
}
p, err := ggGeometryPath(path)
if err != nil {
return Box{}, err
}
if len(path) == 0 {
return Box{}, nil
}
box := p.BoundingBox()
return Box{X: box.Min.X, Y: box.Min.Y, W: box.Max.X - box.Min.X, H: box.Max.Y - box.Min.Y}, nil
return path.Bounds()
}
// Transform 使用GG变换控制点,椭圆弧交给配置的几何后端保持弧线参数
// Transform 使用公共仿射变换,保持椭圆弧和退化投影的折返
// 入参: path 页面路径, matrix 页面变换
// 返回: GeometryPath 新路径, error 路径或变换错误
func (b GGGeometryBackend) Transform(path GeometryPath, matrix Matrix) (GeometryPath, error) {
for _, v := range matrix.Values() {
if !finite(v) {
return nil, fmt.Errorf("invalid geometry transform")
}
}
for _, s := range path {
if s.Verb == GeometryArc {
if b.GeometryBackend == nil {
return nil, ErrBackendUnavailable
}
return b.GeometryBackend.Transform(path, matrix)
}
}
p, err := ggGeometryPath(path)
if err != nil {
return nil, err
}
return geometryFromGG(p.Transform(gg.Matrix{A: matrix.a, B: matrix.c, C: matrix.e, D: matrix.b, E: matrix.d, F: matrix.f})), nil
return path.Transform(matrix)
}
// ggGeometryPath 转换贝塞尔路径,不舍入控制点或静默略过指令
// 入参: path 库路径
// 返回: *gg.Path GG路径, error 不支持的路径指令
func ggGeometryPath(path GeometryPath) (*gg.Path, error) {
p := gg.NewPath()
for _, s := range path {
switch s.Verb {
case GeometryMove:
p.MoveTo(s.End.X, s.End.Y)
case GeometryLine:
p.LineTo(s.End.X, s.End.Y)
case GeometryQuad:
p.QuadraticTo(s.Control1.X, s.Control1.Y, s.End.X, s.End.Y)
case GeometryCubic:
p.CubicTo(s.Control1.X, s.Control1.Y, s.Control2.X, s.Control2.Y, s.End.X, s.End.Y)
case GeometryClose:
p.Close()
default:
return nil, fmt.Errorf("unsupported GG geometry command %d", s.Verb)
// Stroke 为后续区域运算提供描边轮廓,复杂轮廓交给显式补充后端
// 仅单条实线平头描边的公共轮廓无重叠,其他分片不能可靠进入补充后端的布尔运算
// 无补充后端时仍返回公共非零填充轮廓,但不提供区域运算能力
// 入参: path 页面路径, options 描边样式
// 返回: GeometryPath 非零填充轮廓, error 参数或精度限制错误
func (b GGGeometryBackend) Stroke(path GeometryPath, options StrokeOptions) (GeometryPath, error) {
if b.GeometryBackend != nil && !geometrySimpleStroke(path, options) {
if err := path.validate(); err != nil {
return nil, err
}
if err := validateStroke(options); err != nil {
return nil, err
}
return b.GeometryBackend.Stroke(path, options)
}
return p, nil
return b.strokeFill(path, options)
}
// geometryFromGG 返回库自有路径,不保留GG对象
// 入参: path GG路径
// 返回: GeometryPath 库路径
func geometryFromGG(path *gg.Path) GeometryPath {
var result GeometryPath
path.Iterate(func(verb gg.PathVerb, p []float64) {
s := GeometrySegment{}
switch verb {
case gg.MoveTo:
s.Verb, s.End = GeometryMove, Point{p[0], p[1]}
case gg.LineTo:
s.Verb, s.End = GeometryLine, Point{p[0], p[1]}
case gg.QuadTo:
s.Verb, s.Control1, s.End = GeometryQuad, Point{p[0], p[1]}, Point{p[2], p[3]}
case gg.CubicTo:
s.Verb, s.Control1, s.Control2, s.End = GeometryCubic, Point{p[0], p[1]}, Point{p[2], p[3]}, Point{p[4], p[5]}
case gg.Close:
s.Verb = GeometryClose
}
result = append(result, s)
})
return result
// strokeFill 仅供直接填充使用公共展开,不将重叠轮廓送入布尔运算
// 入参: path 页面路径, options 描边样式
// 返回: GeometryPath 非零填充轮廓, error 参数或能力错误
func (b GGGeometryBackend) strokeFill(path GeometryPath, options StrokeOptions) (GeometryPath, error) {
outline, err := path.Stroke(options)
if errors.Is(err, ErrBackendUnavailable) && b.GeometryBackend != nil {
return b.GeometryBackend.Stroke(path, options)
}
return outline, err
}
// geometrySimpleStroke 判断公共描边是否保证生成单个无重叠矩形
// 入参: path 路径, options 描边样式
// 返回: bool 是否可直接参与区域运算
func geometrySimpleStroke(path GeometryPath, options StrokeOptions) bool {
return len(path) == 2 && path[0].Verb == GeometryMove && path[1].Verb == GeometryLine &&
(options.Cap == "" || options.Cap == "Butt") && len(options.Dashes) == 0
}
// Region 将区域解析交给显式提供者,缺失能力时返回错误
// 入参: region 区域, matrix 页面变换
// 返回: GeometryPath 区域路径, error 能力或解析错误
func (b GGGeometryBackend) Region(region *Region, matrix Matrix) (GeometryPath, error) {
if b.GeometryBackend == nil {
return nil, fmt.Errorf("GG region geometry: %w", ErrBackendUnavailable)
}
return b.GeometryBackend.Region(region, matrix)
}
// Normalize 将填充规则整理交给显式提供者
// 入参: path 路径, evenOdd 是否采用奇偶规则
// 返回: GeometryPath 非零填充轮廓, error 能力或几何错误
func (b GGGeometryBackend) Normalize(path GeometryPath, evenOdd bool) (GeometryPath, error) {
if b.GeometryBackend == nil {
return nil, fmt.Errorf("GG normalize geometry: %w", ErrBackendUnavailable)
}
return b.GeometryBackend.Normalize(path, evenOdd)
}
// Combine 将布尔运算交给显式提供者
// 入参: left、right 操作数, operation 运算类型
// 返回: GeometryPath 结果路径, error 能力或几何错误
func (b GGGeometryBackend) Combine(left, right GeometryPath, operation GeometryOperation) (GeometryPath, error) {
if b.GeometryBackend == nil {
return nil, fmt.Errorf("GG boolean geometry: %w", ErrBackendUnavailable)
}
return b.GeometryBackend.Combine(left, right, operation)
}
// Clip 将裁剪解析交给显式提供者
// 入参: renderer 渲染器, clips 裁剪, matrix 页面变换, parent 父裁剪
// 返回: *GeometryPath 裁剪路径, error 能力或几何错误
func (b GGGeometryBackend) Clip(renderer *Renderer, clips *Clips, matrix Matrix, parent *GeometryPath) (*GeometryPath, error) {
if b.GeometryBackend == nil {
return nil, fmt.Errorf("GG clip geometry: %w", ErrBackendUnavailable)
}
return b.GeometryBackend.Clip(renderer, clips, matrix, parent)
}
+76 -29
View File
@@ -35,24 +35,71 @@ func (GGBackend) Name() string { return "gg" }
// 入参: page 只读页面
// 返回: RasterScene 可重复绘制场景, error 准备错误
func (b GGBackend) Prepare(page *RasterPage) (RasterScene, error) {
w, h, err := page.PixelSize()
if err != nil {
return b.prepareShared(page, nil)
}
// PrepareReuse 复用同配置GG场景的派生数据,其他后端场景不参与复用
// 入参: page 新页面, previous 前次场景
// 返回: RasterScene 独立快照, error 准备错误
func (b GGBackend) PrepareReuse(page *RasterPage, previous RasterScene) (RasterScene, error) {
scene, _ := previous.(*ggRasterScene)
return b.prepareShared(page, scene)
}
// prepareShared 为新快照复用同配置的派生缓存,不改变旧快照
// 入参: page 新页面, previous 前次同渲染器场景
// 返回: *ggRasterScene 独立场景, error 准备错误
func (b GGBackend) prepareShared(page *RasterPage, previous *ggRasterScene) (*ggRasterScene, error) {
scene := &ggRasterScene{
mu: &sync.Mutex{},
backend: b,
masks: &renderCache[[32]byte, *image.Alpha]{limit: 16 << 20},
prepared: &renderCache[[32]byte, ggRasterCommand]{limit: 16 << 20},
images: &renderCache[image.Image, *gg.ImageBuf]{limit: 16 << 20},
}
if previous != nil && sameBackend(b.StrokeGeometry, previous.backend.StrokeGeometry) {
scene.mu, scene.masks, scene.prepared, scene.images = previous.mu, previous.masks, previous.prepared, previous.images
}
if err := scene.Update(page); err != nil {
return nil, err
}
return scene, nil
}
// Update 按内容复用未编辑的指令,成功后原子替换场景快照
// 入参: page 编辑后的只读页面
// 返回: error 准备错误,失败时保留原场景
func (scene *ggRasterScene) Update(page *RasterPage) error {
scene.mu.Lock()
defer scene.mu.Unlock()
w, h, err := page.PixelSize()
if err != nil {
return err
}
page = cloneRasterPage(page)
scene := &ggRasterScene{page: page, width: w, height: h, masks: renderCache[[32]byte, *image.Alpha]{limit: 16 << 20}}
commands := make([]ggRasterCommand, 0, len(page.Commands))
for i, cmd := range page.Commands {
if cmd.Stroke != nil && !ggNativeStroke(cmd, page.DPI/25.4) {
cmd, err = b.expandStroke(cmd)
if cmd.Image != nil {
cmd.Stroke = nil
prepared, err := ggPrepareImage(page, cmd, scene.masks, scene.images)
if err != nil {
return nil, fmt.Errorf("gg command %d: %w", i, err)
return fmt.Errorf("gg command %d: %w", i, err)
}
commands = append(commands, prepared)
continue
}
key := rasterCommandKey(page, cmd)
if prepared, ok := scene.prepared.get(key); ok {
commands = append(commands, prepared)
continue
}
if cmd.Stroke != nil && !ggNativeStroke(cmd, page.DPI/25.4) {
cmd, err = scene.backend.expandStroke(cmd)
if err != nil {
return fmt.Errorf("gg command %d: %w", i, err)
}
}
m := page.PixelTransform(cmd.Transform, h)
if cmd.Image != nil {
scene.commands = append(scene.commands, ggRasterCommand{command: cmd})
continue
}
path := gg.NewPath()
for _, s := range cmd.Path {
end, c1, c2 := m.Apply(s.End), m.Apply(s.Control1), m.Apply(s.Control2)
@@ -68,31 +115,25 @@ func (b GGBackend) Prepare(page *RasterPage) (RasterScene, error) {
case RasterClose:
path.Close()
default:
return nil, fmt.Errorf("gg command %d: unsupported path verb %d", i, s.Verb)
return fmt.Errorf("gg command %d: unsupported path verb %d", i, s.Verb)
}
}
paint := gg.NewPaint()
paint.SetFillBrush(gg.SolidBrush{Color: ggRGBA(cmd.Paint.Color)})
if g := cmd.Paint.Gradient; g != nil {
if g.Kind != RasterLinear && g.Kind != RasterRadial {
return nil, fmt.Errorf("gg command %d: unsupported gradient %d", i, g.Kind)
}
inverse, err := m.Inverse()
brush, err := ggGradientBrush(g, m)
if err != nil {
return nil, fmt.Errorf("gg command %d: %w", i, err)
return fmt.Errorf("gg command %d: %w", i, err)
}
paint.SetFillBrush(gg.NewCustomBrush(func(x, y float64) gg.RGBA {
p := inverse.Apply(RasterPoint{X: x, Y: y})
return ggRGBA(g.At(p.X, p.Y))
}))
paint.SetFillBrush(brush)
}
if cmd.EvenOdd && cmd.Stroke == nil {
paint.FillRule = gg.FillRuleEvenOdd
}
if cmd.Clip != nil {
mask, err := rasterClipMask(GGBackend{}, page, cmd.Clip, &scene.masks)
mask, err := rasterClipMask(GGBackend{}, page, cmd.Clip, scene.masks)
if err != nil {
return nil, err
return err
}
if mask.Rect.Empty() {
continue
@@ -103,14 +144,17 @@ func (b GGBackend) Prepare(page *RasterPage) (RasterScene, error) {
if cmd.Stroke != nil {
style, err := ggStroke(*cmd.Stroke, page.DPI/25.4)
if err != nil {
return nil, err
return err
}
paint.SetStroke(style)
paint.SetStrokeBrush(paint.FillBrush())
}
scene.commands = append(scene.commands, ggRasterCommand{command: cmd, path: path, paint: paint})
prepared := ggRasterCommand{command: cmd, path: path, paint: paint}
scene.prepared.put(key, prepared, rasterCommandCost(cmd)+len(paint.ClipMask))
commands = append(commands, prepared)
}
return scene, nil
scene.page, scene.width, scene.height, scene.commands = page, w, h, commands
return nil
}
// ggRasterCommand 保存已转换的像素路径和样式
@@ -122,11 +166,14 @@ type ggRasterCommand struct {
// ggRasterScene 复用路径和局部裁剪,串行保护掩码缓存
type ggRasterScene struct {
mu sync.Mutex
mu *sync.Mutex
backend GGBackend
page *RasterPage
width, height int
commands []ggRasterCommand
masks renderCache[[32]byte, *image.Alpha]
masks *renderCache[[32]byte, *image.Alpha]
prepared *renderCache[[32]byte, ggRasterCommand]
images *renderCache[image.Image, *gg.ImageBuf]
}
// Render 绘制独立像素缓冲,不累积上次内容
@@ -140,8 +187,8 @@ func (s *ggRasterScene) Render() (image.Image, error) {
for i, cmd := range s.commands {
var err error
switch {
case cmd.command.Image != nil:
err = drawRasterImageCommand(GGBackend{}, s.page, img, imagePixelSource(cmd.command.Image), cmd.command, &s.masks)
case cmd.command.Image != nil && cmd.path == nil:
err = drawRasterImageCommand(GGBackend{}, s.page, img, imagePixelSource(cmd.command.Image), cmd.command, s.masks)
case cmd.command.Stroke != nil:
err = raster.Stroke(pixmap, cmd.path, cmd.paint)
default:
+116
View File
@@ -0,0 +1,116 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
import (
"fmt"
"image/color"
"math"
"github.com/gogpu/gg"
)
// ggGradientBrush 仅在颜色插值及延伸等价时使用原生渐变,否则保留OFD采样
// 入参: gradient 局部渐变, matrix 局部到像素变换
// 返回: gg.Brush 画刷, error 类型或变换错误
func ggGradientBrush(gradient *RasterGradient, matrix RasterMatrix) (gg.Brush, error) {
if gradient.Kind != RasterLinear && gradient.Kind != RasterRadial {
return nil, fmt.Errorf("unsupported gradient %d", gradient.Kind)
}
inverse, err := matrix.Inverse()
if err != nil {
return nil, err
}
if brush := ggNativeGradient(gradient, matrix, inverse); brush != nil {
return brush, nil
}
return gg.NewCustomBrush(func(x, y float64) gg.RGBA {
p := inverse.Apply(RasterPoint{X: x, Y: y})
return ggRGBA(gradient.At(p.X, p.Y))
}), nil
}
// ggNativeGradient 将恒定非预乘色的透明度渐变映射为GG原生画刷
// GG使用线性光颜色插值,不接受改变色相、重复节点或单侧透明延伸的OFD渐变
// 入参: g 局部渐变, matrix 正变换, inverse 逆变换
// 返回: gg.Brush 等价原生画刷,不匹配时为空
func ggNativeGradient(g *RasterGradient, matrix, inverse RasterMatrix) gg.Brush {
if len(g.Stops) < 2 {
return nil
}
var reference color.RGBA
for i, stop := range g.Stops {
if !finite(stop.Offset) || stop.Offset < 0 || stop.Offset > 1 || i > 0 && stop.Offset <= g.Stops[i-1].Offset {
return nil
}
if stop.Color.A > reference.A {
reference = stop.Color
}
}
if reference.A == 0 {
return nil
}
for _, stop := range g.Stops {
c := stop.Color
if uint32(c.R)*uint32(reference.A) != uint32(reference.R)*uint32(c.A) ||
uint32(c.G)*uint32(reference.A) != uint32(reference.G)*uint32(c.A) ||
uint32(c.B)*uint32(reference.A) != uint32(reference.B)*uint32(c.A) {
return nil
}
}
extend, period := gg.ExtendPad, 1.0
if spread := g.Spread; spread != nil {
if spread.Extend != 3 {
return nil
}
switch spread.MapType {
case "Repeat":
extend = gg.ExtendRepeat
case "Reflect":
extend = gg.ExtendReflect
}
if extend != gg.ExtendPad && spread.Period > 0 {
period = spread.Period
}
}
stops := make([]gg.ColorStop, len(g.Stops))
for i, stop := range g.Stops {
c := ggRGBA(reference)
c.A = float64(stop.Color.A) / 255
stops[i] = gg.ColorStop{Offset: stop.Offset, Color: c}
}
start := matrix.Apply(g.Start)
if g.Kind == RasterLinear {
dx, dy := g.End.X-g.Start.X, g.End.Y-g.Start.Y
length := dx*dx + dy*dy
if !rasterPositive(length) {
return nil
}
// 渐变参数为协向量,非等比缩放或错切不能直接变换两个端点
x, y := (inverse[0]*dx+inverse[1]*dy)/length, (inverse[2]*dx+inverse[3]*dy)/length
factor := period / (x*x + y*y)
brush := gg.NewLinearGradientBrush(start.X, start.Y, start.X+x*factor, start.Y+y*factor)
brush.Stops, brush.Extend = stops, extend
return brush
}
xscale, yscale := math.Hypot(matrix[0], matrix[1]), math.Hypot(matrix[2], matrix[3])
if g.Start != g.End || g.R0 != 0 || !rasterPositive(g.R1) ||
math.Abs(xscale-yscale) > 1e-12*xscale || math.Abs(matrix[0]*matrix[2]+matrix[1]*matrix[3]) > 1e-12*xscale*yscale {
return nil
}
brush := gg.NewRadialGradientBrush(start.X, start.Y, 0, g.R1*xscale*period)
brush.Stops, brush.Extend = stops, extend
return brush
}
+86
View File
@@ -0,0 +1,86 @@
// 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"
"math"
"github.com/gogpu/gg"
)
// ggNativeImage 判断GG最近邻采样与公共高质量采样是否等价
// 仅接受整数像素平移及不会重复预乘的八位图片,缩放、错切和旋转沿用公共路径
// 入参: src 源像素, matrix 像素变换
// 返回: bool 是否可使用原生图片
func ggNativeImage(src image.Image, matrix RasterMatrix) bool {
if matrix[0] != 1 || matrix[1] != 0 || matrix[2] != 0 || matrix[3] != 1 ||
!finite(matrix[4]) || !finite(matrix[5]) || matrix[4] != math.Trunc(matrix[4]) || matrix[5] != math.Trunc(matrix[5]) || src.Bounds().Empty() {
return false
}
switch src := src.(type) {
case *image.NRGBA, *image.Gray:
return true
case *image.RGBA:
return src.Opaque()
}
return false
}
// ggPrepareImage 复用GG图片采样器和裁剪,不能等价采样时保留公共图像指令
// 工厂方法只构造采样器,不创建上下文设备或调用可能隐藏错误的DrawImageEx
// 入参: page 页面, command 图片指令, masks 裁剪缓存, images 图片适配缓存
// 返回: ggRasterCommand 预编译图片, error 裁剪错误
func ggPrepareImage(page *RasterPage, command RasterCommand, masks *renderCache[[32]byte, *image.Alpha], images *renderCache[image.Image, *gg.ImageBuf]) (ggRasterCommand, error) {
result := ggRasterCommand{command: command}
_, height, _ := page.PixelSize()
matrix := page.PixelTransform(command.Transform, height)
src := imagePixelSource(command.Image)
if !ggNativeImage(src, matrix) {
return result, nil
}
buffer, ok := images.get(src)
if !ok {
buffer = gg.ImageBufFromImage(src)
images.put(src, buffer, src.Bounds().Dx()*src.Bounds().Dy()*4+256)
}
bounds := src.Bounds()
x, y := matrix[4]+float64(bounds.Min.X), matrix[5]+float64(bounds.Min.Y)
pattern := new(gg.Context).CreateImagePattern(buffer, 0, 0, bounds.Dx(), bounds.Dy()).(*gg.ImagePattern)
pattern.SetClamp(true)
pattern.SetTransform(gg.Translate(x, y))
paint := gg.NewPaint()
paint.SetFillBrush(gg.BrushFromPattern(pattern))
if command.Clip != nil {
mask, err := rasterClipMask(GGBackend{}, page, command.Clip, masks)
if err != nil {
return result, err
}
if mask.Rect.Empty() {
result.path, result.paint = gg.NewPath(), paint
return result, nil
}
paint.ClipMask, paint.ClipMaskW, paint.ClipMaskH = mask.Pix, mask.Stride, mask.Rect.Dy()
paint.ClipMaskX, paint.ClipMaskY = mask.Rect.Min.X, mask.Rect.Min.Y
}
path := gg.NewPath()
path.MoveTo(x, y)
path.LineTo(x+float64(bounds.Dx()), y)
path.LineTo(x+float64(bounds.Dx()), y+float64(bounds.Dy()))
path.LineTo(x, y+float64(bounds.Dy()))
path.Close()
result.path, result.paint = path, paint
return result, nil
}
+10 -1
View File
@@ -16,6 +16,11 @@ package ofdgo
import "fmt"
// ggStrokeFiller 将仅用于光栅填充的描边与可参与区域运算的轮廓分开
type ggStrokeFiller interface {
strokeFill(path GeometryPath, options StrokeOptions) (GeometryPath, error)
}
// ggNativeStroke 判断GG当前可保真处理的描边范围
// 入参: command 描边指令, scale 每毫米像素数
// 返回: bool 是否可使用原生描边
@@ -63,7 +68,11 @@ func (b GGBackend) expandStroke(command RasterCommand) (RasterCommand, error) {
if err != nil {
return RasterCommand{}, err
}
path, err = b.StrokeGeometry.Stroke(path, *command.Stroke)
if filler, ok := b.StrokeGeometry.(ggStrokeFiller); ok {
path, err = filler.strokeFill(path, *command.Stroke)
} else {
path, err = b.StrokeGeometry.Stroke(path, *command.Stroke)
}
if err != nil {
return RasterCommand{}, err
}
-1
View File
@@ -22,7 +22,6 @@ import (
)
// Outline 使用默认几何后端获取SVG轮廓,自定义后端使用Renderer.PathOutline
// 入参: p 路径对象
// 返回: string SVG路径数据, error 错误信息
func (p PathObject) Outline() (string, error) {
r := &Renderer{backends: defaultRenderBackends()}
+2 -2
View File
@@ -97,9 +97,9 @@ func (d *sm3Digest) Write(p []byte) (int, error) {
return nn, nil
}
// Sum 返回SM3杂凑值
// Sum 将SM3杂凑值追加到前缀数据后,不改变当前状态
// 入参: in 前缀数据
// 返回: []byte 杂凑值
// 返回: []byte 前缀数据及杂凑值
func (d *sm3Digest) Sum(in []byte) []byte {
dd := *d
hash := dd.checkSum()
+4 -1
View File
@@ -27,6 +27,9 @@ import (
// 入参: obj 文字对象, value 原文, options 段落排版选项, metrics 字体度量
// 返回: error 错误信息
func LayoutText(obj *TextObject, value string, options TextLayout, metrics FontMetrics) error {
if obj == nil || metrics == nil {
return fmt.Errorf("text object and font metrics are required")
}
if metrics.UnitsPerEm() == 0 {
return fmt.Errorf("font units per em must be positive")
}
@@ -73,7 +76,7 @@ func LayoutText(obj *TextObject, value string, options TextLayout, metrics FontM
if lineHeight == 0 {
lineHeight = math.Max(obj.Size, float64(int(ascender)+int(descender)+int(gap))*unit)
}
if options.Shape {
if options.Shape || options.Shaping != (TextShapeOptions{}) {
return layoutShapedText(obj, value, options, metrics, width, lineHeight, hScale, float64(ascender)*unit)
}
var codes []TextCode
+53 -12
View File
@@ -23,6 +23,12 @@ import (
"github.com/go-text/typesetting/segmenter"
)
// configuredTextShaper 将会话选项传递给每次换行后的塑形
type configuredTextShaper struct {
FontShaperOptions
options TextShapeOptions
}
// layoutShapedText 将显式塑形结果保存为标准文字定位,成功前不修改对象
// 入参: obj 文字对象, value 原文, options 排版选项, metrics 字体度量, width 行宽, lineHeight 行高, hScale 横向比例, baseline 首行基线
// 返回: error 塑形或排版错误
@@ -31,12 +37,19 @@ func layoutShapedText(obj *TextObject, value string, options TextLayout, metrics
if !ok {
return fmt.Errorf("font shaping: %w", ErrBackendUnavailable)
}
if options.Shaping != (TextShapeOptions{}) {
configured, ok := metrics.(FontShaperOptions)
if !ok {
return fmt.Errorf("font shaping options: %w", ErrBackendUnavailable)
}
shaper = configuredTextShaper{FontShaperOptions: configured, options: options.Shaping}
}
var codes []TextCode
var transforms []CGTransform
offset := 0
for _, paragraph := range strings.Split(value, "\n") {
runes := []rune(paragraph)
_, advances, err := shapeTextLine(shaper, runes, obj.Font, obj.Size, hScale, options.LetterSpacing)
paragraphGlyphs, advances, err := shapeTextLine(shaper, runes, obj.Font, obj.Size, hScale, options.LetterSpacing)
if err != nil {
return err
}
@@ -48,9 +61,12 @@ func layoutShapedText(obj *TextObject, value string, options TextLayout, metrics
indent = options.FirstLineIndent
}
available := width - indent
glyphs, lineAdvances, err := shapeTextLine(shaper, runes[line[0]:line[1]], obj.Font, obj.Size, hScale, options.LetterSpacing)
if err != nil {
return err
glyphs, lineAdvances := paragraphGlyphs, advances
if line[0] != 0 || line[1] != len(runes) {
glyphs, lineAdvances, err = shapeTextLine(shaper, runes[line[0]:line[1]], obj.Font, obj.Size, hScale, options.LetterSpacing)
if err != nil {
return err
}
}
for options.Wrap && shapedLineWidth(runes[line[0]:line[1]], lineAdvances, options.LetterSpacing) > available {
var seg segmenter.Segmenter
@@ -77,6 +93,11 @@ func layoutShapedText(obj *TextObject, value string, options TextLayout, metrics
}
chars := runes[line[0]:line[1]]
x, delta := alignTextLine(chars, lineAdvances, available, options.Align, i+1 < len(lines), options.LetterSpacing)
if options.Shaping.Direction == "rtl" {
for j := len(chars) - 1; j >= 0 && chars[j] == ' '; j-- {
x -= lineAdvances[j]
}
}
x += options.LeftIndent + indent
y := baseline + float64(len(codes))*lineHeight
if !finite(x) || !finite(y) {
@@ -99,7 +120,12 @@ func layoutShapedText(obj *TextObject, value string, options TextLayout, metrics
var dx, dy []string
var lastX, lastY float64
for j, glyph := range glyphs {
gx, gy := x+glyph.X*hScale+extra[glyph.Cluster], y+glyph.Y
shift := extra[glyph.Cluster]
if options.Shaping.Direction == "rtl" {
// 逻辑簇后的间距位于视觉左侧,整行不计末尾字距
shift = extra[len(chars)] - extra[glyph.Cluster] - options.LetterSpacing
}
gx, gy := x+glyph.X*hScale+shift, y+glyph.Y
if !finite(gx) || !finite(gy) {
return fmt.Errorf("shaped position exceeds finite range")
}
@@ -136,6 +162,13 @@ func layoutShapedText(obj *TextObject, value string, options TextLayout, metrics
return nil
}
// ShapeText 使用固定选项塑形单行原文
// 入参: value 原文, size 毫米字号
// 返回: []ShapedGlyph 字形, error 塑形错误
func (s configuredTextShaper) ShapeText(value string, size float64) ([]ShapedGlyph, error) {
return s.ShapeTextWithOptions(value, size, s.options)
}
// shapeTextLine 检查可选后端的簇映射,按原文字符分配塑形步进
// 入参: shaper 字体塑形器, runes 单行字符, font 字体标识, size 字号, scale 横向比例, spacing 字距
// 返回: []ShapedGlyph 字形, []float64 含字距的字符步进, error 塑形错误
@@ -150,17 +183,11 @@ func shapeTextLine(shaper FontShaper, runes []rune, font string, size, scale, sp
advances := make([]float64, len(runes))
var missing []rune
for i, glyph := range glyphs {
if glyph.Cluster < 0 || glyph.Cluster >= len(runes) || i > 0 && glyph.Cluster < glyphs[i-1].Cluster || !finite(glyph.X) || !finite(glyph.Y) || !finite(glyph.Advance) {
if glyph.Cluster < 0 || glyph.Cluster >= len(runes) || i > 0 && glyph.Cluster < glyphs[i-1].Cluster || glyph.Glyph >= shaper.NumGlyphs() || !finite(glyph.X) || !finite(glyph.Y) || !finite(glyph.Advance) {
return nil, nil, fmt.Errorf("invalid shaped glyph")
}
if glyph.Glyph == 0 {
missing = append(missing, runes[glyph.Cluster])
}
advances[glyph.Cluster] += glyph.Advance * scale
}
if err := missingGlyphError(font, missing); err != nil {
return nil, nil, err
}
for i := 0; i < len(glyphs); {
end := i + 1
for end < len(glyphs) && glyphs[end].Cluster == glyphs[i].Cluster {
@@ -171,12 +198,26 @@ func shapeTextLine(shaper FontShaper, runes []rune, font string, size, scale, sp
next = glyphs[end].Cluster
}
start := glyphs[i].Cluster
if slices.ContainsFunc(glyphs[i:end], func(glyph ShapedGlyph) bool { return glyph.Glyph == 0 }) {
before := len(missing)
for _, char := range runes[start:next] {
if shaper.GlyphIndex(char) == 0 {
missing = append(missing, char)
}
}
if len(missing) == before {
missing = append(missing, runes[start:next]...)
}
}
advance := advances[start] / float64(next-start)
for j := start; j < next; j++ {
advances[j] = advance
}
i = end
}
if err := missingGlyphError(font, missing); err != nil {
return nil, nil, err
}
spaceTextAdvances(runes, advances, spacing)
if slices.ContainsFunc(advances, func(value float64) bool { return !finite(value) }) {
return nil, nil, fmt.Errorf("shaped advance exceeds finite range")
+3 -3
View File
@@ -263,10 +263,10 @@ func (e *Editor) writeParts(write func(string, []byte, bool) error, progress edi
}
x.end("CommonData")
x.start("Pages", nil)
for i, page := range e.pages {
for _, page := range e.pages {
var attrs ofdAttrs
attrs.add("ID", page.ID)
attrs.add("BaseLoc", fmt.Sprintf("Pages/%d/Content.xml", i+1))
attrs.add("BaseLoc", packagePagePath("", page.ID))
x.start("Page", attrs)
x.end("Page")
}
@@ -284,7 +284,7 @@ func (e *Editor) writeParts(write func(string, []byte, bool) error, progress edi
if err := progress.report("pages", i, len(e.pages)); err != nil {
return err
}
name := fmt.Sprintf("Doc_0/Pages/%d/Content.xml", i+1)
name := packagePagePath(e.packageDirectory(), page.ID)
if len(e.origins) == 0 {
if err := writeXML(name, func(x *ofdXML) { x.page(page) }); err != nil {
return err