diff --git a/README.md b/README.md index 460245b..1024d5a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # OFDGo [![PkgGoDev](https://pkg.go.dev/badge/github.com/xiaoqidun/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) 授权协议 diff --git a/ofdgo.go b/ofdgo.go index afd814f..ecaf5b3 100644 --- a/ofdgo.go +++ b/ofdgo.go @@ -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 ( diff --git a/ofdgo_editor.go b/ofdgo_editor.go new file mode 100644 index 0000000..6a2d2ae --- /dev/null +++ b/ofdgo_editor.go @@ -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() +} diff --git a/ofdgo_font_util.go b/ofdgo_font_util.go index 806c721..cbd9d5e 100644 --- a/ofdgo_font_util.go +++ b/ofdgo_font_util.go @@ -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 包装字体字符 diff --git a/ofdgo_reader.go b/ofdgo_reader.go index 64e3d39..78c47b3 100644 --- a/ofdgo_reader.go +++ b/ofdgo_reader.go @@ -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() } diff --git a/ofdgo_text.go b/ofdgo_text.go index edce6d6..af941ea 100644 --- a/ofdgo_text.go +++ b/ofdgo_text.go @@ -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轴偏移量数组 diff --git a/ofdgo_writer.go b/ofdgo_writer.go new file mode 100644 index 0000000..f7eb7ba --- /dev/null +++ b/ofdgo_writer.go @@ -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) +}