mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 16:44:23 +08:00
1 parent
580e7043c4
commit
d6f70de7be
40 files changed
+2299
-407
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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中的对象引用
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -16,7 +16,7 @@ package ofdgo
|
||||
|
||||
import "image/color"
|
||||
|
||||
// ColorStop 保存预乘RGBA颜色节点及渐变区间位置
|
||||
// ColorStop 保存渐变区间位置及预乘RGBA颜色节点
|
||||
type ColorStop struct {
|
||||
Offset float64
|
||||
Color color.RGBA
|
||||
|
||||
+2
-2
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user