mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-30 01:27:26 +08:00
1 parent
4d65896def
commit
b71bb6bfb8
7 files changed
+1202
-11
No files matched your search
@@ -1,5 +1,5 @@
|
||||
# OFDGo [](https://pkg.go.dev/github.com/xiaoqidun/ofdgo)
|
||||
首个原生、全平台兼容的纯 Go 语言 OFD 渲染库
|
||||
首个原生、全平台兼容的纯 Go 语言 OFD 读写库
|
||||
|
||||
|
||||
# 在线体验
|
||||
@@ -105,5 +105,49 @@ func main() {
|
||||
}
|
||||
```
|
||||
|
||||
# 创建文档
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/xiaoqidun/ofdgo"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 1. 创建文档
|
||||
editor := ofdgo.NewEditor()
|
||||
editor.Info.Title = "示例文档"
|
||||
page, err := editor.AddPage(210, 297)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// 2. 添加文字
|
||||
fontData, err := os.ReadFile("font.ttf")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fontID, err := editor.AddFont(ofdgo.FontFile{Data: fontData}, 0)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
_, err = editor.AddText(page, ofdgo.Box{X: 20, Y: 20, W: 170, H: 15}, "你好,OFDGo!", fontID, 6)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// 3. 保存文档
|
||||
ofdFile, err := os.Create("test.ofd")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer ofdFile.Close()
|
||||
if _, err := editor.WriteTo(ofdFile); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
# 授权协议
|
||||
本项目使用 [Apache License 2.0](https://github.com/xiaoqidun/ofdgo/blob/main/LICENSE) 授权协议
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package ofdgo 首个原生、全平台兼容的纯 Go 语言 OFD 渲染库
|
||||
// Package ofdgo 首个原生、全平台兼容的纯 Go 语言 OFD 读写库
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
|
||||
+580
@@ -0,0 +1,580 @@
|
||||
// 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"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"image"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/tdewolff/font"
|
||||
)
|
||||
|
||||
// Editor 新建OFD文档,长度单位为毫米,页面索引从0开始,实例需串行使用
|
||||
// Info可修改文档元数据,页面和资源通过方法添加,不修改已有OFD文件
|
||||
type Editor struct {
|
||||
Info DocInfo
|
||||
pages []PageContent
|
||||
resources []editorResource
|
||||
fonts map[string]*font.SFNT
|
||||
images map[string]bool
|
||||
resourceID map[editorResourceKey]string
|
||||
maxID int
|
||||
}
|
||||
|
||||
// editorResource 文档内嵌资源
|
||||
type editorResource struct {
|
||||
name string
|
||||
data []byte
|
||||
font *Font
|
||||
image *MultiMedia
|
||||
}
|
||||
|
||||
// editorResourceKey 资源内容和字体集合索引
|
||||
type editorResourceKey struct {
|
||||
checksum [32]byte
|
||||
index int
|
||||
}
|
||||
|
||||
// NewEditor 创建空白文档,保存前至少添加一页
|
||||
// 返回: *Editor 文档编辑器
|
||||
func NewEditor() *Editor {
|
||||
var id [16]byte
|
||||
rand.Read(id[:])
|
||||
id[6] = id[6]&0x0f | 0x40
|
||||
id[8] = id[8]&0x3f | 0x80
|
||||
return &Editor{
|
||||
Info: DocInfo{
|
||||
DocID: hex.EncodeToString(id[:]),
|
||||
CreationDate: time.Now().Format("2006-01-02"),
|
||||
},
|
||||
fonts: make(map[string]*font.SFNT),
|
||||
images: make(map[string]bool),
|
||||
resourceID: make(map[editorResourceKey]string),
|
||||
}
|
||||
}
|
||||
|
||||
// AddPage 添加页面及默认正文图层
|
||||
// 入参: width 页面宽度, height 页面高度
|
||||
// 返回: int 页面索引, error 错误信息
|
||||
func (e *Editor) AddPage(width, height float64) (int, error) {
|
||||
if !finite(width) || !finite(height) || width <= 0 || height <= 0 {
|
||||
return 0, fmt.Errorf("page dimensions must be finite and positive")
|
||||
}
|
||||
e.pages = append(e.pages, PageContent{
|
||||
ID: e.nextID(),
|
||||
Area: PageArea{PhysicalBox: fmt.Sprintf("0 0 %s %s", ofdNumber(width), ofdNumber(height))},
|
||||
Content: Content{Layer: []Layer{{ID: e.nextID(), Type: "Body"}}},
|
||||
})
|
||||
return len(e.pages) - 1, nil
|
||||
}
|
||||
|
||||
// nextID 分配文档内唯一标识
|
||||
// 返回: string 对象标识
|
||||
func (e *Editor) nextID() string {
|
||||
e.maxID++
|
||||
return strconv.Itoa(e.maxID)
|
||||
}
|
||||
|
||||
// AddFont 嵌入OpenType字体,集合字体按索引提取,重复资源复用标识
|
||||
// 入参: file 字体文件, index 集合内字体索引,单字体为0
|
||||
// 返回: string 字体资源标识, error 错误信息
|
||||
func (e *Editor) AddFont(file FontFile, index int) (string, error) {
|
||||
if index < 0 {
|
||||
return "", fmt.Errorf("font index must not be negative")
|
||||
}
|
||||
data, err := font.ToSFNT(file.Data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
key := editorResourceKey{checksum: sha256.Sum256(data), index: index}
|
||||
if id, ok := e.resourceID[key]; ok {
|
||||
return id, nil
|
||||
}
|
||||
if bytes.HasPrefix(data, []byte("ttcf")) {
|
||||
data, err = extractCollectionFont(data, index)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
index = 0
|
||||
} else {
|
||||
data = bytes.Clone(data)
|
||||
}
|
||||
sfnt, err := font.ParseSFNT(data, index)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
name := editorFontName(sfnt, font.NamePostScript, font.NameFull, font.NameFontFamily)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("font has no name")
|
||||
}
|
||||
id := e.nextID()
|
||||
extension := ".ttf"
|
||||
if sfnt.IsCFF {
|
||||
extension = ".otf"
|
||||
}
|
||||
resource := editorResource{
|
||||
name: "Doc_0/Res/Font_" + id + extension,
|
||||
data: data,
|
||||
font: &Font{
|
||||
ID: id, FontName: name,
|
||||
FamilyName: editorFontName(sfnt, font.NamePreferredFamily, font.NameFontFamily),
|
||||
Charset: "unicode",
|
||||
Bold: sfnt.Head.MacStyle[0],
|
||||
Italic: sfnt.Head.MacStyle[1],
|
||||
FixedWidth: sfnt.Post.IsFixedPitch != 0,
|
||||
},
|
||||
}
|
||||
resource.font.FontFile = strings.TrimPrefix(resource.name, "Doc_0/Res/")
|
||||
e.resources = append(e.resources, resource)
|
||||
e.fonts[id] = sfnt
|
||||
e.resourceID[key] = id
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// editorFontName 获取字体中的名称
|
||||
// 入参: sfnt 字体, names 名称类型,按顺序查找
|
||||
// 返回: string 字体名称
|
||||
func editorFontName(sfnt *font.SFNT, names ...font.NameID) string {
|
||||
for _, name := range names {
|
||||
for _, record := range sfnt.Name.Get(name) {
|
||||
if value := record.String(); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// AddImage 嵌入PNG或JPEG图片,保留原始编码,重复资源复用标识
|
||||
// 入参: data 图片数据
|
||||
// 返回: string 图片资源标识, error 错误信息
|
||||
func (e *Editor) AddImage(data []byte) (string, error) {
|
||||
config, format, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if (format != "png" && format != "jpeg") || config.Width <= 0 || config.Height <= 0 {
|
||||
return "", fmt.Errorf("only PNG and JPEG images are supported")
|
||||
}
|
||||
key := editorResourceKey{checksum: sha256.Sum256(data), index: -1}
|
||||
if id, ok := e.resourceID[key]; ok {
|
||||
return id, nil
|
||||
}
|
||||
id := e.nextID()
|
||||
extension := format
|
||||
if extension == "jpeg" {
|
||||
extension = "jpg"
|
||||
}
|
||||
name := "Image_" + id + "." + extension
|
||||
e.resources = append(e.resources, editorResource{
|
||||
name: "Doc_0/Res/" + name,
|
||||
data: bytes.Clone(data),
|
||||
image: &MultiMedia{ID: id, Type: "Image", Format: strings.ToUpper(format), MediaFile: name},
|
||||
})
|
||||
e.images[id] = true
|
||||
e.resourceID[key] = id
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// AddObject 按添加顺序放入正文图层,支持文字、路径和图片,自动分配对象ID
|
||||
// 首版支持基本颜色和直接资源引用,不支持动作、裁剪、渐变及复合图元
|
||||
// 入参: page 页面索引, object 对象内容,添加后不再引用调用方的可变数据
|
||||
// 返回: string 对象标识, error 错误信息
|
||||
func (e *Editor) AddObject(page int, object GraphicObject) (string, error) {
|
||||
if page < 0 || page >= len(e.pages) {
|
||||
return "", fmt.Errorf("page index %d out of range", page)
|
||||
}
|
||||
id := strconv.Itoa(e.maxID + 1)
|
||||
var boundary, ctm, drawParam, join string
|
||||
var alpha *int
|
||||
var clips *Clips
|
||||
var actions []Action
|
||||
var fill, stroke *FillColor
|
||||
switch object.Type {
|
||||
case "TextObject":
|
||||
obj := &object.TextObject
|
||||
if err := e.prepareText(obj); err != nil {
|
||||
return "", err
|
||||
}
|
||||
obj.ID = id
|
||||
boundary, ctm, drawParam = obj.Boundary, obj.CTM, obj.DrawParam
|
||||
join = obj.Join
|
||||
alpha, clips, actions = obj.Alpha, obj.Clips, obj.Actions
|
||||
fill, stroke = obj.FillColor, (*FillColor)(obj.StrokeColor)
|
||||
case "PathObject":
|
||||
obj := &object.PathObject
|
||||
if err := creationPath(obj.AbbreviatedData); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if obj.Rule != "" && obj.Rule != "NonZero" && obj.Rule != "Even-Odd" {
|
||||
return "", fmt.Errorf("invalid fill rule %q", obj.Rule)
|
||||
}
|
||||
if !finite(obj.LineWidth) || obj.LineWidth < 0 || !finite(obj.MiterLimit) || obj.MiterLimit < 0 {
|
||||
return "", fmt.Errorf("invalid path stroke dimensions")
|
||||
}
|
||||
if obj.Cap != "" && obj.Cap != "Butt" && obj.Cap != "Round" && obj.Cap != "Square" {
|
||||
return "", fmt.Errorf("invalid line cap %q", obj.Cap)
|
||||
}
|
||||
if obj.DashOffset != nil && !finite(*obj.DashOffset) {
|
||||
return "", fmt.Errorf("invalid dash offset")
|
||||
}
|
||||
if obj.DashPattern != "" {
|
||||
values, err := creationNumbers(obj.DashPattern, len(strings.Fields(obj.DashPattern)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
total := 0.0
|
||||
for _, value := range values {
|
||||
if value < 0 {
|
||||
return "", fmt.Errorf("dash lengths must not be negative")
|
||||
}
|
||||
total += value
|
||||
}
|
||||
if total == 0 {
|
||||
return "", fmt.Errorf("dash pattern must have a positive length")
|
||||
}
|
||||
}
|
||||
obj.ID = id
|
||||
boundary, ctm, drawParam = obj.Boundary, obj.CTM, obj.DrawParam
|
||||
join = obj.Join
|
||||
alpha, clips, actions = obj.Alpha, obj.Clips, obj.Actions
|
||||
fill, stroke = obj.FillColor, (*FillColor)(obj.StrokeColor)
|
||||
case "ImageObject":
|
||||
obj := &object.ImageObject
|
||||
if !e.images[obj.ResourceID] || (obj.ImageMask != "" && !e.images[obj.ImageMask]) {
|
||||
return "", fmt.Errorf("image resource not found")
|
||||
}
|
||||
if obj.Border != nil {
|
||||
return "", fmt.Errorf("image borders are not supported for creation")
|
||||
}
|
||||
obj.ID = id
|
||||
boundary, ctm = obj.Boundary, obj.CTM
|
||||
alpha, clips, actions = obj.Alpha, obj.Clips, obj.Actions
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported object type %q", object.Type)
|
||||
}
|
||||
box, err := creationBox(boundary)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if ctm != "" {
|
||||
if _, err := creationNumbers(ctm, 6); err != nil {
|
||||
return "", fmt.Errorf("invalid CTM: %w", err)
|
||||
}
|
||||
} else if object.Type == "ImageObject" {
|
||||
object.ImageObject.CTM = fmt.Sprintf("%s 0 0 %s 0 0", ofdNumber(box.W), ofdNumber(box.H))
|
||||
}
|
||||
if drawParam != "" || clips != nil || len(actions) != 0 {
|
||||
return "", fmt.Errorf("draw parameter references, clips and actions are not supported for creation")
|
||||
}
|
||||
if alpha != nil && (*alpha < 0 || *alpha > 255) {
|
||||
return "", fmt.Errorf("alpha must be between 0 and 255")
|
||||
}
|
||||
for _, color := range []*FillColor{fill, stroke} {
|
||||
if err := creationColor(color); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if join != "" && join != "Miter" && join != "Round" && join != "Bevel" {
|
||||
return "", fmt.Errorf("invalid line join %q", join)
|
||||
}
|
||||
data, err := objectXML(object)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
object = GraphicObject{Type: object.Type}
|
||||
var target any
|
||||
switch object.Type {
|
||||
case "TextObject":
|
||||
target = &object.TextObject
|
||||
case "PathObject":
|
||||
target = &object.PathObject
|
||||
case "ImageObject":
|
||||
target = &object.ImageObject
|
||||
}
|
||||
if err := xml.Unmarshal(data, target); err != nil {
|
||||
return "", err
|
||||
}
|
||||
e.maxID++
|
||||
e.pages[page].Content.Layer[0].Objects = append(e.pages[page].Content.Layer[0].Objects, object)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// AddText 添加单行文字,使用嵌入字体的字宽定位,不进行段落排版和复杂文字塑形
|
||||
// 入参: page 页面索引, box 文字边界, value 原文, fontID 字体资源标识, size 字号
|
||||
// 返回: string 对象标识, error 错误信息
|
||||
func (e *Editor) AddText(page int, box Box, value, fontID string, size float64) (string, error) {
|
||||
sfnt, ok := e.fonts[fontID]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("font resource %q not found", fontID)
|
||||
}
|
||||
if !utf8.ValidString(value) || strings.ContainsAny(value, "\r\n\t") || value == "" {
|
||||
return "", fmt.Errorf("text must be a nonempty UTF-8 line")
|
||||
}
|
||||
runes := []rune(value)
|
||||
deltas := make([]string, 0, len(runes)-1)
|
||||
for i, char := range runes {
|
||||
glyph := sfnt.GlyphIndex(char)
|
||||
if glyph == 0 {
|
||||
return "", fmt.Errorf("font does not contain U+%04X", char)
|
||||
}
|
||||
if i+1 < len(runes) {
|
||||
deltas = append(deltas, ofdNumber(float64(sfnt.GlyphAdvance(glyph))*size/float64(sfnt.UnitsPerEm())))
|
||||
}
|
||||
}
|
||||
ascender, _, _ := sfnt.VerticalMetrics()
|
||||
return e.AddObject(page, GraphicObject{Type: "TextObject", TextObject: TextObject{
|
||||
Boundary: fmt.Sprintf("%s %s %s %s", ofdNumber(box.X), ofdNumber(box.Y), ofdNumber(box.W), ofdNumber(box.H)),
|
||||
Font: fontID, Size: size,
|
||||
TextCode: []TextCode{{X: "0", Y: ofdNumber(float64(ascender) * size / float64(sfnt.UnitsPerEm())), DeltaX: strings.Join(deltas, " "), Value: escapeOFDText(value)}},
|
||||
}})
|
||||
}
|
||||
|
||||
// prepareText 校验并补全标准文字定位,避免依赖阅读器的缺省字距补偿
|
||||
// 入参: obj 文字对象
|
||||
// 返回: error 错误信息
|
||||
func (e *Editor) prepareText(obj *TextObject) error {
|
||||
if e.fonts[obj.Font] == nil || !finite(obj.Size) || obj.Size <= 0 {
|
||||
return fmt.Errorf("text requires an embedded font and a positive finite size")
|
||||
}
|
||||
if len(obj.TextCode) == 0 {
|
||||
return fmt.Errorf("text codes are empty")
|
||||
}
|
||||
if obj.VScale != 0 || obj.Decoration != "" || len(obj.CGTransform) != 0 {
|
||||
return fmt.Errorf("text extensions and glyph transforms are not supported for creation")
|
||||
}
|
||||
if !finite(obj.HScale) || obj.HScale < 0 || !finite(obj.LineWidth) || obj.LineWidth < 0 || !finite(obj.MiterLimit) || obj.MiterLimit < 0 {
|
||||
return fmt.Errorf("invalid text dimensions")
|
||||
}
|
||||
if obj.Weight < 0 || obj.Weight > 900 || obj.Weight%100 != 0 {
|
||||
return fmt.Errorf("invalid text weight")
|
||||
}
|
||||
for _, direction := range []int{obj.ReadDirection, obj.CharDirection} {
|
||||
if direction != 0 && direction != 90 && direction != 180 && direction != 270 {
|
||||
return fmt.Errorf("invalid text direction %d", direction)
|
||||
}
|
||||
}
|
||||
obj.TextCode = append([]TextCode(nil), obj.TextCode...)
|
||||
x, y := "", ""
|
||||
for i := range obj.TextCode {
|
||||
code := &obj.TextCode[i]
|
||||
if code.Index != "" || !utf8.ValidString(code.Value) {
|
||||
return fmt.Errorf("text requires Unicode character data")
|
||||
}
|
||||
if code.X != "" {
|
||||
x = code.X
|
||||
}
|
||||
if code.Y != "" {
|
||||
y = code.Y
|
||||
}
|
||||
for _, coordinate := range []string{x, y} {
|
||||
if _, err := creationNumbers(coordinate, 1); err != nil {
|
||||
return fmt.Errorf("invalid text origin: %w", err)
|
||||
}
|
||||
}
|
||||
code.X, code.Y = x, y
|
||||
runes := textCodeRunes(code.Value)
|
||||
if len(runes) == 0 {
|
||||
return fmt.Errorf("text code is empty")
|
||||
}
|
||||
for _, char := range runes {
|
||||
if e.fonts[obj.Font].GlyphIndex(char) == 0 {
|
||||
return fmt.Errorf("font does not contain U+%04X", char)
|
||||
}
|
||||
}
|
||||
for _, delta := range []string{code.DeltaX, code.DeltaY} {
|
||||
if delta != "" {
|
||||
if err := creationDeltas(delta, len(runes)-1); err != nil {
|
||||
return fmt.Errorf("invalid text delta: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(runes) > 1 && code.DeltaX == "" && code.DeltaY == "" {
|
||||
code.DeltaX = strings.TrimSpace(strings.Repeat("0 ", len(runes)-1))
|
||||
}
|
||||
code.Value = escapeOFDText(string(runes))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// creationDeltas 校验文字位移数组及g重复编码
|
||||
// 入参: value 位移数组, count 所需位移数量
|
||||
// 返回: error 错误信息
|
||||
func creationDeltas(value string, count int) error {
|
||||
fields := strings.Fields(value)
|
||||
numbers := 0
|
||||
for i := 0; i < len(fields); i++ {
|
||||
repeat := 1
|
||||
if fields[i] == "g" {
|
||||
if i+2 >= len(fields) {
|
||||
return fmt.Errorf("incomplete delta repetition")
|
||||
}
|
||||
var err error
|
||||
repeat, err = strconv.Atoi(fields[i+1])
|
||||
if err != nil || repeat <= 0 {
|
||||
return fmt.Errorf("invalid delta repetition")
|
||||
}
|
||||
i += 2
|
||||
}
|
||||
if _, err := creationNumbers(fields[i], 1); err != nil {
|
||||
return err
|
||||
}
|
||||
if repeat > count-numbers {
|
||||
return fmt.Errorf("too many text deltas")
|
||||
}
|
||||
numbers += repeat
|
||||
}
|
||||
if numbers != count {
|
||||
return fmt.Errorf("expected %d text deltas", count)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// creationPath 校验紧缩路径的命令及有限数值参数
|
||||
// 入参: value 路径数据
|
||||
// 返回: error 错误信息
|
||||
func creationPath(value string) error {
|
||||
tokens := strings.Fields(value)
|
||||
if len(tokens) == 0 || (tokens[0] != "M" && tokens[0] != "S") {
|
||||
return fmt.Errorf("path must start with M or S")
|
||||
}
|
||||
for i := 0; i < len(tokens); {
|
||||
command := tokens[i]
|
||||
i++
|
||||
count := 0
|
||||
switch command {
|
||||
case "M", "S", "L":
|
||||
count = 2
|
||||
case "Q":
|
||||
count = 4
|
||||
case "B":
|
||||
count = 6
|
||||
case "A":
|
||||
count = 7
|
||||
case "C":
|
||||
default:
|
||||
return fmt.Errorf("invalid path command %q", command)
|
||||
}
|
||||
if len(tokens)-i < count {
|
||||
return fmt.Errorf("missing arguments for path command %s", command)
|
||||
}
|
||||
values, err := creationNumbers(strings.Join(tokens[i:i+count], " "), count)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if command == "A" && (values[0] < 0 || values[1] < 0 || (values[3] != 0 && values[3] != 1) || (values[4] != 0 && values[4] != 1)) {
|
||||
return fmt.Errorf("invalid arc radii or flags")
|
||||
}
|
||||
i += count
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// creationNumbers 解析创建接口的有限十进制数值序列
|
||||
// 入参: value 数值序列, count 数值数量
|
||||
// 返回: []float64 数值, error 错误信息
|
||||
func creationNumbers(value string, count int) ([]float64, error) {
|
||||
fields := strings.Fields(value)
|
||||
if len(fields) != count {
|
||||
return nil, fmt.Errorf("expected %d numbers", count)
|
||||
}
|
||||
values := make([]float64, count)
|
||||
for i, field := range fields {
|
||||
number, err := strconv.ParseFloat(field, 64)
|
||||
if err != nil || !finite(number) || strings.ContainsAny(field, "xX_") {
|
||||
return nil, fmt.Errorf("invalid number %q", field)
|
||||
}
|
||||
values[i] = number
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// creationBox 校验对象边界
|
||||
// 入参: value 边界描述
|
||||
// 返回: Box 矩形, error 错误信息
|
||||
func creationBox(value string) (Box, error) {
|
||||
values, err := creationNumbers(value, 4)
|
||||
if err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
if values[2] <= 0 || values[3] <= 0 {
|
||||
return Box{}, fmt.Errorf("boundary dimensions must be positive")
|
||||
}
|
||||
return Box{X: values[0], Y: values[1], W: values[2], H: values[3]}, nil
|
||||
}
|
||||
|
||||
// creationColor 校验默认RGB颜色空间中的基本颜色
|
||||
// 入参: color 颜色
|
||||
// 返回: error 错误信息
|
||||
func creationColor(color *FillColor) error {
|
||||
if color == nil {
|
||||
return nil
|
||||
}
|
||||
if color.ColorSpace != "" || color.Index != nil || color.Pattern != nil || color.AxialShd != nil || color.RadialShd != nil || color.unsupported {
|
||||
return fmt.Errorf("only direct RGB colors are supported for creation")
|
||||
}
|
||||
values, err := creationNumbers(color.Value, 3)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, value := range values {
|
||||
if value < 0 || value > 255 || math.Trunc(value) != value {
|
||||
return fmt.Errorf("RGB channels must be integers between 0 and 255")
|
||||
}
|
||||
}
|
||||
if color.Alpha != nil && (*color.Alpha < 0 || *color.Alpha > 255) {
|
||||
return fmt.Errorf("color alpha must be between 0 and 255")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// finite 判断数值是否有限
|
||||
// 入参: value 数值
|
||||
// 返回: bool 是否有限
|
||||
func finite(value float64) bool {
|
||||
return !math.IsNaN(value) && !math.IsInf(value, 0)
|
||||
}
|
||||
|
||||
// ofdNumber 格式化OFD数值
|
||||
// 入参: value 数值
|
||||
// 返回: string 数值文本
|
||||
func ofdNumber(value float64) string {
|
||||
return strconv.FormatFloat(value, 'f', -1, 64)
|
||||
}
|
||||
|
||||
// escapeOFDText 转义控制字符、反斜线和XML不允许的字符
|
||||
// 入参: value 原文
|
||||
// 返回: string OFD文字编码
|
||||
func escapeOFDText(value string) string {
|
||||
var text strings.Builder
|
||||
for _, char := range value {
|
||||
if char < ' ' || char == '\\' || char == '\ufffe' || char == '\uffff' {
|
||||
fmt.Fprintf(&text, "\\%04X", char)
|
||||
} else {
|
||||
text.WriteRune(char)
|
||||
}
|
||||
}
|
||||
return text.String()
|
||||
}
|
||||
@@ -17,9 +17,57 @@ package ofdgo
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// extractCollectionFont 提取集合中的单个OpenType字体,保留表内容并重新计算校验和
|
||||
// 入参: data 集合数据, index 字体索引
|
||||
// 返回: []byte 单字体数据, error 错误信息
|
||||
func extractCollectionFont(data []byte, index int) ([]byte, error) {
|
||||
if len(data) < 12 || string(data[:4]) != "ttcf" {
|
||||
return nil, fmt.Errorf("invalid font collection header")
|
||||
}
|
||||
version := binary.BigEndian.Uint32(data[4:])
|
||||
count := uint64(binary.BigEndian.Uint32(data[8:]))
|
||||
if (version != 0x00010000 && version != 0x00020000) || count > uint64(len(data)-12)/4 || index < 0 || uint64(index) >= count {
|
||||
return nil, fmt.Errorf("invalid font collection index or version")
|
||||
}
|
||||
offset := uint64(binary.BigEndian.Uint32(data[12+index*4:]))
|
||||
if offset > uint64(len(data)-12) {
|
||||
return nil, fmt.Errorf("invalid font offset")
|
||||
}
|
||||
header := data[offset : offset+12]
|
||||
if string(header[:4]) != "\x00\x01\x00\x00" && string(header[:4]) != "OTTO" && string(header[:4]) != "true" {
|
||||
return nil, fmt.Errorf("invalid OpenType header")
|
||||
}
|
||||
tableCount := uint64(binary.BigEndian.Uint16(header[4:]))
|
||||
offset += 12
|
||||
if tableCount == 0 || tableCount > (uint64(len(data))-offset)/16 {
|
||||
return nil, fmt.Errorf("invalid font table directory")
|
||||
}
|
||||
tables := make(map[string][]byte, tableCount)
|
||||
for i := uint64(0); i < tableCount; i++ {
|
||||
entry := data[offset+i*16 : offset+(i+1)*16]
|
||||
tag := string(entry[:4])
|
||||
start := uint64(binary.BigEndian.Uint32(entry[8:]))
|
||||
length := uint64(binary.BigEndian.Uint32(entry[12:]))
|
||||
if start > uint64(len(data)) || length > uint64(len(data))-start {
|
||||
return nil, fmt.Errorf("invalid %s font table range", tag)
|
||||
}
|
||||
if _, exists := tables[tag]; exists {
|
||||
return nil, fmt.Errorf("duplicate %s font table", tag)
|
||||
}
|
||||
tables[tag] = data[start : start+length]
|
||||
}
|
||||
if len(tables["head"]) < 12 {
|
||||
return nil, fmt.Errorf("invalid head font table")
|
||||
}
|
||||
tables["head"] = bytes.Clone(tables["head"])
|
||||
clear(tables["head"][8:12])
|
||||
return serializeOTF(tables)
|
||||
}
|
||||
|
||||
// packedGlyphRune 获取包装字体字符
|
||||
// 入参: gid 字形ID
|
||||
// 返回: rune 包装字体字符
|
||||
|
||||
+17
-8
@@ -46,6 +46,7 @@ type Reader struct {
|
||||
Annots map[string][]Annotation
|
||||
fileIndex map[string]*zip.File
|
||||
fileIndexFold map[string]*zip.File
|
||||
files map[string][]byte
|
||||
}
|
||||
|
||||
// Close 关闭阅读器
|
||||
@@ -62,12 +63,14 @@ func (r *Reader) Close() error {
|
||||
func (r *Reader) initRoot() error {
|
||||
r.fileIndex = make(map[string]*zip.File)
|
||||
r.fileIndexFold = make(map[string]*zip.File)
|
||||
for _, f := range r.Zip.File {
|
||||
name := cleanPackagePath(f.Name)
|
||||
r.fileIndex[name] = f
|
||||
fold := strings.ToLower(name)
|
||||
if _, ok := r.fileIndexFold[fold]; !ok {
|
||||
r.fileIndexFold[fold] = f
|
||||
if r.Zip != nil {
|
||||
for _, f := range r.Zip.File {
|
||||
name := cleanPackagePath(f.Name)
|
||||
r.fileIndex[name] = f
|
||||
fold := strings.ToLower(name)
|
||||
if _, ok := r.fileIndexFold[fold]; !ok {
|
||||
r.fileIndexFold[fold] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
data, err := r.readFile("OFD.xml")
|
||||
@@ -90,22 +93,28 @@ func (r *Reader) initRoot() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// readFile 读取压缩包内的文件
|
||||
// readFile 读取文档内的文件
|
||||
// 入参: name 文件名
|
||||
// 返回: []byte 文件内容, error 错误信息
|
||||
func (r *Reader) readFile(name string) ([]byte, error) {
|
||||
name = cleanPackagePath(name)
|
||||
if data, ok := r.files[name]; ok {
|
||||
return bytes.Clone(data), nil
|
||||
}
|
||||
if f, ok := r.packageFile(name); ok {
|
||||
return readZipFile(f)
|
||||
}
|
||||
return nil, fmt.Errorf("file not found: %s", name)
|
||||
}
|
||||
|
||||
// openFile 打开压缩包内的文件流
|
||||
// openFile 打开文档内的文件流
|
||||
// 入参: name 文件名
|
||||
// 返回: io.ReadCloser 文件流, error 错误信息
|
||||
func (r *Reader) openFile(name string) (io.ReadCloser, error) {
|
||||
name = cleanPackagePath(name)
|
||||
if data, ok := r.files[name]; ok {
|
||||
return io.NopCloser(bytes.NewReader(data)), nil
|
||||
}
|
||||
if f, ok := r.packageFile(name); ok {
|
||||
return f.Open()
|
||||
}
|
||||
|
||||
+26
-1
@@ -18,6 +18,7 @@ import (
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf16"
|
||||
|
||||
"github.com/tdewolff/canvas"
|
||||
canvastext "github.com/tdewolff/canvas/text"
|
||||
@@ -322,7 +323,31 @@ func textCodeRunes(value string) []rune {
|
||||
if strings.ContainsAny(value, "\r\n") {
|
||||
value = strings.TrimSpace(value)
|
||||
}
|
||||
return []rune(value)
|
||||
runes := []rune(value)
|
||||
if !strings.Contains(value, "\\") {
|
||||
return runes
|
||||
}
|
||||
written := 0
|
||||
for i := 0; i < len(runes); i++ {
|
||||
char := runes[i]
|
||||
if char == '\\' && i+4 < len(runes) {
|
||||
if code, err := strconv.ParseUint(string(runes[i+1:i+5]), 16, 16); err == nil {
|
||||
decoded := rune(code)
|
||||
if !utf16.IsSurrogate(decoded) {
|
||||
char = decoded
|
||||
i += 4
|
||||
} else if decoded >= 0xd800 && decoded <= 0xdbff && i+9 < len(runes) && runes[i+5] == '\\' {
|
||||
if low, err := strconv.ParseUint(string(runes[i+6:i+10]), 16, 16); err == nil && low >= 0xdc00 && low <= 0xdfff {
|
||||
char = utf16.DecodeRune(decoded, rune(low))
|
||||
i += 9
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
runes[written] = char
|
||||
written++
|
||||
}
|
||||
return runes[:written]
|
||||
}
|
||||
|
||||
// GetDeltaX 获取X轴偏移量数组
|
||||
|
||||
+485
@@ -0,0 +1,485 @@
|
||||
// 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 (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const ofdNamespace = "http://www.ofdspec.org/2016"
|
||||
|
||||
// WriteTo 逐个条目写出OFD,不关闭调用方输出流,出错时应丢弃本次输出
|
||||
// 入参: writer 输出流
|
||||
// 返回: int64 已写入字节数, error 错误信息
|
||||
func (e *Editor) WriteTo(writer io.Writer) (int64, error) {
|
||||
if err := e.validate(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
output := &ofdCountingWriter{writer: writer}
|
||||
archive := zip.NewWriter(output)
|
||||
err := e.writeParts(func(name string, data []byte, compressed bool) error {
|
||||
method := uint16(zip.Deflate)
|
||||
if compressed {
|
||||
method = zip.Store
|
||||
}
|
||||
entry, err := archive.CreateHeader(&zip.FileHeader{Name: name, Method: method})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = entry.Write(data)
|
||||
return err
|
||||
})
|
||||
if err == nil {
|
||||
err = archive.Close()
|
||||
}
|
||||
return output.count, err
|
||||
}
|
||||
|
||||
// Reader 获取当前文档的独立内存快照,不进行ZIP压缩,后续修改不影响已有快照
|
||||
// 返回的Reader可用于现有渲染、搜索和导出接口,二进制资源内部共享只读数据
|
||||
// 返回: *Reader 阅读器, error 错误信息
|
||||
func (e *Editor) Reader() (*Reader, error) {
|
||||
if err := e.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := &Reader{files: make(map[string][]byte)}
|
||||
if err := e.writeParts(func(name string, data []byte, _ bool) error {
|
||||
r.files[name] = data
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.initRoot(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := r.Doc(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// validate 校验文档保存条件
|
||||
// 返回: error 错误信息
|
||||
func (e *Editor) validate() error {
|
||||
if len(e.pages) == 0 {
|
||||
return fmt.Errorf("document must contain at least one page")
|
||||
}
|
||||
if data, err := hex.DecodeString(e.Info.DocID); err != nil || len(data) != 16 {
|
||||
return fmt.Errorf("DocID must contain 32 hexadecimal characters")
|
||||
}
|
||||
for _, date := range []string{e.Info.CreationDate, e.Info.ModDate} {
|
||||
if date != "" {
|
||||
if _, err := time.Parse("2006-01-02", date); err != nil {
|
||||
return fmt.Errorf("invalid document date: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeParts 生成根节点、文档、资源索引、页面及二进制资源
|
||||
// 入参: write 条目写入方法
|
||||
// 返回: error 错误信息
|
||||
func (e *Editor) writeParts(write func(string, []byte, bool) error) error {
|
||||
writeXML := func(name string, encode func(*ofdXML)) error {
|
||||
var data bytes.Buffer
|
||||
x := newOFDXML(&data)
|
||||
encode(x)
|
||||
if err := x.finish(); err != nil {
|
||||
return err
|
||||
}
|
||||
return write(name, data.Bytes(), false)
|
||||
}
|
||||
if err := writeXML("OFD.xml", func(x *ofdXML) {
|
||||
x.root("OFD", ofdAttrs{{Name: xml.Name{Local: "Version"}, Value: "1.0"}, {Name: xml.Name{Local: "DocType"}, Value: "OFD"}})
|
||||
x.start("DocBody", nil)
|
||||
x.start("DocInfo", nil)
|
||||
for _, field := range [][2]string{
|
||||
{"DocID", e.Info.DocID}, {"Title", e.Info.Title}, {"Author", e.Info.Author},
|
||||
{"Subject", e.Info.Subject}, {"Abstract", e.Info.Abstract},
|
||||
{"CreationDate", e.Info.CreationDate}, {"ModDate", e.Info.ModDate},
|
||||
{"Creator", "xiaoqidun/ofdgo"},
|
||||
} {
|
||||
if field[1] != "" {
|
||||
x.text(field[0], field[1])
|
||||
}
|
||||
}
|
||||
if e.Info.CustomDatas != nil && len(e.Info.CustomDatas.CustomData) != 0 {
|
||||
x.start("CustomDatas", nil)
|
||||
for _, item := range e.Info.CustomDatas.CustomData {
|
||||
attrs := ofdAttrs{{Name: xml.Name{Local: "Name"}, Value: item.Name}}
|
||||
x.start("CustomData", attrs)
|
||||
x.token(xml.CharData(item.Value))
|
||||
x.end("CustomData")
|
||||
}
|
||||
x.end("CustomDatas")
|
||||
}
|
||||
x.end("DocInfo")
|
||||
x.text("DocRoot", "Doc_0/Document.xml")
|
||||
x.end("DocBody")
|
||||
x.end("OFD")
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeXML("Doc_0/Document.xml", func(x *ofdXML) {
|
||||
x.root("Document", nil)
|
||||
x.start("CommonData", nil)
|
||||
x.text("MaxUnitID", strconv.Itoa(e.maxID))
|
||||
x.start("PageArea", nil)
|
||||
x.text("PhysicalBox", e.pages[0].Area.PhysicalBox)
|
||||
x.end("PageArea")
|
||||
if len(e.resources) != 0 {
|
||||
x.text("DocumentRes", "DocumentRes.xml")
|
||||
}
|
||||
x.end("CommonData")
|
||||
x.start("Pages", nil)
|
||||
for i, page := range e.pages {
|
||||
var attrs ofdAttrs
|
||||
attrs.add("ID", page.ID)
|
||||
attrs.add("BaseLoc", fmt.Sprintf("Pages/%d/Content.xml", i+1))
|
||||
x.start("Page", attrs)
|
||||
x.end("Page")
|
||||
}
|
||||
x.end("Pages")
|
||||
x.end("Document")
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(e.resources) != 0 {
|
||||
if err := writeXML("Doc_0/DocumentRes.xml", e.writeResources); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for i, page := range e.pages {
|
||||
if err := writeXML(fmt.Sprintf("Doc_0/Pages/%d/Content.xml", i+1), func(x *ofdXML) {
|
||||
x.root("Page", nil)
|
||||
x.start("Area", nil)
|
||||
x.text("PhysicalBox", page.Area.PhysicalBox)
|
||||
x.end("Area")
|
||||
x.start("Content", nil)
|
||||
for _, layer := range page.Content.Layer {
|
||||
var attrs ofdAttrs
|
||||
attrs.add("ID", layer.ID)
|
||||
attrs.add("Type", layer.Type)
|
||||
x.start("Layer", attrs)
|
||||
for _, object := range layer.Objects {
|
||||
x.object(object, false)
|
||||
}
|
||||
x.end("Layer")
|
||||
}
|
||||
x.end("Content")
|
||||
x.end("Page")
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, resource := range e.resources {
|
||||
if err := write(resource.name, resource.data, resource.image != nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeResources 写出文档资源索引
|
||||
// 入参: x XML编码器
|
||||
func (e *Editor) writeResources(x *ofdXML) {
|
||||
x.root("Res", ofdAttrs{{Name: xml.Name{Local: "BaseLoc"}, Value: "Res"}})
|
||||
if len(e.fonts) != 0 {
|
||||
x.start("Fonts", nil)
|
||||
for _, resource := range e.resources {
|
||||
if resource.font == nil {
|
||||
continue
|
||||
}
|
||||
font := resource.font
|
||||
var attrs ofdAttrs
|
||||
attrs.add("ID", font.ID)
|
||||
attrs.add("FontName", font.FontName)
|
||||
attrs.add("FamilyName", font.FamilyName)
|
||||
attrs.add("Charset", font.Charset)
|
||||
if font.Bold {
|
||||
attrs.add("Bold", "true")
|
||||
}
|
||||
if font.Italic {
|
||||
attrs.add("Italic", "true")
|
||||
}
|
||||
if font.FixedWidth {
|
||||
attrs.add("FixedWidth", "true")
|
||||
}
|
||||
x.start("Font", attrs)
|
||||
x.text("FontFile", font.FontFile)
|
||||
x.end("Font")
|
||||
}
|
||||
x.end("Fonts")
|
||||
}
|
||||
if len(e.images) != 0 {
|
||||
x.start("MultiMedias", nil)
|
||||
for _, resource := range e.resources {
|
||||
if resource.image == nil {
|
||||
continue
|
||||
}
|
||||
image := resource.image
|
||||
var attrs ofdAttrs
|
||||
attrs.add("ID", image.ID)
|
||||
attrs.add("Type", image.Type)
|
||||
attrs.add("Format", image.Format)
|
||||
x.start("MultiMedia", attrs)
|
||||
x.text("MediaFile", image.MediaFile)
|
||||
x.end("MultiMedia")
|
||||
}
|
||||
x.end("MultiMedias")
|
||||
}
|
||||
x.end("Res")
|
||||
}
|
||||
|
||||
// ofdCountingWriter 记录实际输出字节数
|
||||
type ofdCountingWriter struct {
|
||||
writer io.Writer
|
||||
count int64
|
||||
}
|
||||
|
||||
// Write 写入并累计字节数
|
||||
// 入参: data 数据
|
||||
// 返回: int 字节数, error 错误信息
|
||||
func (w *ofdCountingWriter) Write(data []byte) (int, error) {
|
||||
n, err := w.writer.Write(data)
|
||||
w.count += int64(n)
|
||||
if err == nil && n != len(data) {
|
||||
err = io.ErrShortWrite
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// ofdXML 使用标准ofd命名空间写出XML
|
||||
type ofdXML struct {
|
||||
encoder *xml.Encoder
|
||||
err error
|
||||
}
|
||||
|
||||
// newOFDXML 创建XML编码器
|
||||
// 入参: writer 输出流
|
||||
// 返回: *ofdXML 编码器
|
||||
func newOFDXML(writer io.Writer) *ofdXML {
|
||||
_, err := io.WriteString(writer, xml.Header)
|
||||
return &ofdXML{encoder: xml.NewEncoder(writer), err: err}
|
||||
}
|
||||
|
||||
// token 写入XML标记并保留首个错误
|
||||
// 入参: token XML标记
|
||||
func (x *ofdXML) token(token xml.Token) {
|
||||
if x.err == nil {
|
||||
x.err = x.encoder.EncodeToken(token)
|
||||
}
|
||||
}
|
||||
|
||||
// root 写入声明命名空间的根节点
|
||||
// 入参: name 节点名, attrs 属性
|
||||
func (x *ofdXML) root(name string, attrs ofdAttrs) {
|
||||
attrs.add("xmlns:ofd", ofdNamespace)
|
||||
x.start(name, attrs)
|
||||
}
|
||||
|
||||
// start 写入节点起始标记
|
||||
// 入参: name 节点名, attrs 属性
|
||||
func (x *ofdXML) start(name string, attrs ofdAttrs) {
|
||||
x.token(xml.StartElement{Name: xml.Name{Local: "ofd:" + name}, Attr: attrs})
|
||||
}
|
||||
|
||||
// end 写入节点结束标记
|
||||
// 入参: name 节点名
|
||||
func (x *ofdXML) end(name string) {
|
||||
x.token(xml.EndElement{Name: xml.Name{Local: "ofd:" + name}})
|
||||
}
|
||||
|
||||
// text 写入文本节点
|
||||
// 入参: name 节点名, value 文本
|
||||
func (x *ofdXML) text(name, value string) {
|
||||
x.start(name, nil)
|
||||
x.token(xml.CharData(value))
|
||||
x.end(name)
|
||||
}
|
||||
|
||||
// finish 完成XML输出
|
||||
// 返回: error 错误信息
|
||||
func (x *ofdXML) finish() error {
|
||||
if x.err != nil {
|
||||
return x.err
|
||||
}
|
||||
return x.encoder.Close()
|
||||
}
|
||||
|
||||
// ofdAttrs OFD节点属性
|
||||
type ofdAttrs []xml.Attr
|
||||
|
||||
// add 添加非空属性
|
||||
// 入参: name 属性名, value 属性值
|
||||
func (a *ofdAttrs) add(name, value string) {
|
||||
if value != "" {
|
||||
*a = append(*a, xml.Attr{Name: xml.Name{Local: name}, Value: value})
|
||||
}
|
||||
}
|
||||
|
||||
// number 添加非零数值属性
|
||||
// 入参: name 属性名, value 数值
|
||||
func (a *ofdAttrs) number(name string, value float64) {
|
||||
if value != 0 {
|
||||
a.add(name, ofdNumber(value))
|
||||
}
|
||||
}
|
||||
|
||||
// flag 添加显式布尔属性
|
||||
// 入参: name 属性名, value 布尔值
|
||||
func (a *ofdAttrs) flag(name string, value *bool) {
|
||||
if value != nil {
|
||||
a.add(name, strconv.FormatBool(*value))
|
||||
}
|
||||
}
|
||||
|
||||
// alpha 添加显式透明度
|
||||
// 入参: value 透明度
|
||||
func (a *ofdAttrs) alpha(value *int) {
|
||||
if value != nil {
|
||||
a.add("Alpha", strconv.Itoa(*value))
|
||||
}
|
||||
}
|
||||
|
||||
// objectXML 编码单个对象,用于隔离调用方数据并统一读写语义
|
||||
// 入参: object 图形对象
|
||||
// 返回: []byte XML数据, error 错误信息
|
||||
func objectXML(object GraphicObject) ([]byte, error) {
|
||||
var data bytes.Buffer
|
||||
x := newOFDXML(&data)
|
||||
x.object(object, true)
|
||||
if err := x.finish(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data.Bytes(), nil
|
||||
}
|
||||
|
||||
// object 按OFD规定顺序写出基本对象
|
||||
// 入参: object 图形对象, root 是否为根节点
|
||||
func (x *ofdXML) object(object GraphicObject, root bool) {
|
||||
var attrs ofdAttrs
|
||||
var fill, stroke *FillColor
|
||||
if root {
|
||||
attrs.add("xmlns:ofd", ofdNamespace)
|
||||
}
|
||||
switch object.Type {
|
||||
case "TextObject":
|
||||
obj := object.TextObject
|
||||
attrs.add("ID", obj.ID)
|
||||
attrs.add("Boundary", obj.Boundary)
|
||||
attrs.add("CTM", obj.CTM)
|
||||
attrs.add("Font", obj.Font)
|
||||
attrs.number("Size", obj.Size)
|
||||
attrs.number("HScale", obj.HScale)
|
||||
attrs.number("Weight", float64(obj.Weight))
|
||||
attrs.number("ReadDirection", float64(obj.ReadDirection))
|
||||
attrs.number("CharDirection", float64(obj.CharDirection))
|
||||
attrs.number("LineWidth", obj.LineWidth)
|
||||
attrs.number("MiterLimit", obj.MiterLimit)
|
||||
attrs.add("Join", obj.Join)
|
||||
if obj.Italic {
|
||||
attrs.add("Italic", "true")
|
||||
}
|
||||
attrs.flag("Visible", obj.Visible)
|
||||
attrs.flag("Stroke", obj.Stroke)
|
||||
attrs.flag("Fill", obj.Fill)
|
||||
attrs.alpha(obj.Alpha)
|
||||
fill, stroke = obj.FillColor, (*FillColor)(obj.StrokeColor)
|
||||
case "PathObject":
|
||||
obj := object.PathObject
|
||||
attrs.add("ID", obj.ID)
|
||||
attrs.add("Boundary", obj.Boundary)
|
||||
attrs.add("CTM", obj.CTM)
|
||||
attrs.number("LineWidth", obj.LineWidth)
|
||||
attrs.number("MiterLimit", obj.MiterLimit)
|
||||
attrs.add("Join", obj.Join)
|
||||
attrs.add("Cap", obj.Cap)
|
||||
attrs.add("Rule", obj.Rule)
|
||||
attrs.add("DashPattern", obj.DashPattern)
|
||||
if obj.DashOffset != nil {
|
||||
attrs.add("DashOffset", ofdNumber(*obj.DashOffset))
|
||||
}
|
||||
attrs.flag("Visible", obj.Visible)
|
||||
attrs.flag("Stroke", obj.Stroke)
|
||||
attrs.flag("Fill", obj.Fill)
|
||||
attrs.alpha(obj.Alpha)
|
||||
fill, stroke = obj.FillColor, (*FillColor)(obj.StrokeColor)
|
||||
case "ImageObject":
|
||||
obj := object.ImageObject
|
||||
attrs.add("ID", obj.ID)
|
||||
attrs.add("Boundary", obj.Boundary)
|
||||
attrs.add("CTM", obj.CTM)
|
||||
attrs.add("ResourceID", obj.ResourceID)
|
||||
attrs.add("ImageMask", obj.ImageMask)
|
||||
attrs.flag("Visible", obj.Visible)
|
||||
attrs.alpha(obj.Alpha)
|
||||
}
|
||||
x.start(object.Type, attrs)
|
||||
if object.Type == "PathObject" {
|
||||
x.color("StrokeColor", stroke)
|
||||
x.color("FillColor", fill)
|
||||
x.text("AbbreviatedData", object.PathObject.AbbreviatedData)
|
||||
} else if object.Type == "TextObject" {
|
||||
x.color("FillColor", fill)
|
||||
x.color("StrokeColor", stroke)
|
||||
for _, code := range object.TextObject.TextCode {
|
||||
var attrs ofdAttrs
|
||||
attrs.add("X", code.X)
|
||||
attrs.add("Y", code.Y)
|
||||
attrs.add("DeltaX", code.DeltaX)
|
||||
attrs.add("DeltaY", code.DeltaY)
|
||||
x.textCode(code.Value, attrs)
|
||||
}
|
||||
}
|
||||
x.end(object.Type)
|
||||
}
|
||||
|
||||
// textCode 使用XML字符引用转义空格,避免依赖阅读器保留原始空白
|
||||
// 入参: value OFD文字内容, attrs 文字定位属性
|
||||
func (x *ofdXML) textCode(value string, attrs ofdAttrs) {
|
||||
if x.err != nil {
|
||||
return
|
||||
}
|
||||
var escaped bytes.Buffer
|
||||
if x.err = xml.EscapeText(&escaped, []byte(value)); x.err != nil {
|
||||
return
|
||||
}
|
||||
content := struct {
|
||||
Text []byte `xml:",innerxml"`
|
||||
}{Text: bytes.ReplaceAll(escaped.Bytes(), []byte(" "), []byte(" "))}
|
||||
x.err = x.encoder.EncodeElement(content, xml.StartElement{Name: xml.Name{Local: "ofd:TextCode"}, Attr: attrs})
|
||||
}
|
||||
|
||||
// color 写出基本颜色
|
||||
// 入参: name 节点名, color 颜色
|
||||
func (x *ofdXML) color(name string, color *FillColor) {
|
||||
if color == nil {
|
||||
return
|
||||
}
|
||||
var attrs ofdAttrs
|
||||
attrs.add("Value", color.Value)
|
||||
attrs.alpha(color.Alpha)
|
||||
x.start(name, attrs)
|
||||
x.end(name)
|
||||
}
|
||||
Reference in new issue
Block a user