// 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" "maps" "path" "reflect" "slices" "strconv" "strings" "time" ) const ofdNamespace = "http://www.ofdspec.org/2016" // editorProgress 保存准备阶段的进度检查点,内存预览使用nil type editorProgress func(stage string, completed, total int) error // report 回报进度并原样传递调用方停止原因 // 入参: stage 阶段, completed 已处理项, total 总项数 // 返回: error 调用方停止原因 func (progress editorProgress) report(stage string, completed, total int) error { if progress != nil { return progress(stage, completed, total) } return nil } // WriteTo 逐个条目写出OFD,不关闭调用方输出流,出错时应丢弃本次输出 // 新增资源仅写入实际引用的部分,静态TrueType轮廓字体按实际文字生成子集 // 原有字体在全包引用可确定时保留字形编号裁剪;编辑资源、复杂字体及无法确定的引用保持原样 // 重开子集文档后输入未包含的文字,需要通过AddFont注册完整字体并替换原字体引用 // 入参: writer 输出流 // 返回: int64 已写入字节数, error 错误信息 func (e *Editor) WriteTo(writer io.Writer) (int64, error) { if e.encryption != nil { return e.writeEncrypted(writer) } return e.writePlaintext(writer) } // writePlaintext 写出未加密包,供显式明文输出与加密封装共用 // 入参: writer 输出流 // 返回: int64 写入字节数, error 错误信息 func (e *Editor) writePlaintext(writer io.Writer) (int64, error) { if err := e.validate(); err != nil { return 0, err } progress := editorProgress(e.OnWriteProgress) fonts, err := e.subsetFonts(progress) if err != nil { return 0, err } if e.source != nil { return e.writeSource(writer, fonts, progress) } if err := progress.report("write", 0, 0); err != nil { return 0, err } output := &ofdCountingWriter{writer: writer} archive := zip.NewWriter(output) err = e.writeParts(func(name string, data []byte, compressed bool) error { if subset, ok := fonts[name]; ok { data = subset } 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 }, progress) if err == nil { err = archive.Close() } return output.count, err } // WritePagesTo 按指定顺序将页面另存为独立OFD,不修改当前文档或撤销记录 // 保留描述信息并生成新文档标识,迁移相关资源、目录、注释和签章外观,移除指向未选页面的跳转 // 快照只准备所选页面,关联引用由页面导入流程统一筛选 // 签名凭据不代表另存文件验签有效;进度与取消复用OnWriteProgress,另含snapshot、ids和commit阶段 // 入参: writer 输出流, indexes 从0开始的页面索引,不可重复或为空 // 返回: int64 已写入字节数, error 错误信息 func (e *Editor) WritePagesTo(writer io.Writer, indexes []int) (int64, error) { if len(indexes) == 0 { return 0, fmt.Errorf("no pages selected") } if err := e.validatePageIndexes(indexes); err != nil { return 0, err } progress := editorProgress(e.OnWriteProgress) if err := progress.report("snapshot", 0, 0); err != nil { return 0, err } snapshot := *e snapshot.pages = make([]PageContent, len(indexes)) if e.source != nil { source := *e.source source.pages = make(map[string]*editorSourcePage, len(indexes)) source.annotationPages = nil for _, index := range indexes { id := e.pages[index].ID if page := e.source.pages[id]; page != nil { source.pages[id] = page } } snapshot.source = &source } positions := make([]int, len(indexes)) for i, index := range indexes { snapshot.pages[i] = e.pages[index] positions[i] = i } reader, err := snapshot.reader(progress) if err != nil { return 0, err } defer reader.Close() selected := NewEditor() selected.encryption = e.encryption id := selected.Info.DocID selected.Info = cloneEditorData(e.Info) selected.Info.DocID = id selected.OnWriteProgress = e.OnWriteProgress if _, err := selected.ImportPagesWithOptions(reader, positions, 0, PageImportOptions{Outlines: true, OnProgress: e.OnWriteProgress}); err != nil { return 0, err } return selected.WriteTo(writer) } // Reader 获取当前文档的独立内存快照,不进行ZIP压缩,后续修改不影响已有快照 // 返回的Reader可用于现有渲染、搜索和导出接口,新增资源仅包含实际引用的部分,二进制数据内部共享只读 // 返回: *Reader 阅读器, error 错误信息 func (e *Editor) Reader() (*Reader, error) { return e.reader(nil) } // reader 生成内存快照,保存时复用准备进度,预览时不触发回调 // 入参: progress 准备进度回调 // 返回: *Reader 阅读器, error 错误信息 func (e *Editor) reader(progress editorProgress) (*Reader, error) { if err := e.validate(); err != nil { return nil, err } if e.source != nil { return e.sourceReader(progress) } r := &Reader{files: make(map[string][]byte), encryption: e.encryption} if err := e.writeParts(func(name string, data []byte, _ bool) error { r.files[name] = data return nil }, progress); err != nil { return nil, err } if err := r.initRoot(); err != nil { return nil, err } if _, err := r.Doc(); err != nil { return nil, err } for i, page := range r.doc.Pages.Page { r.pageHeaderCache[r.ResPath(page.BaseLoc)] = PageContent{Area: PageArea{PhysicalBox: e.pages[i].Area.PhysicalBox}} } 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 e.source == nil || e.Info.DocID != e.source.info.DocID { if data, err := hex.DecodeString(e.Info.DocID); err != nil || len(data) != 16 { return fmt.Errorf("DocID must contain 32 hexadecimal characters") } } for i, date := range []string{e.Info.CreationDate, e.Info.ModDate} { if e.source != nil && date == []string{e.source.info.CreationDate, e.source.info.ModDate}[i] { continue } if date != "" { if _, err := time.Parse("2006-01-02", date); err != nil { return fmt.Errorf("invalid document date: %w", err) } } } return nil } // writeParts 生成根节点、文档、资源索引、页面及二进制资源 // 入参: write 条目写入方法, progress 保存进度回调,预览时为nil // 返回: error 错误信息 func (e *Editor) writeParts(write func(string, []byte, bool) error, progress editorProgress) error { if len(e.removedPages) != 0 { output := write write = func(name string, data []byte, binary bool) error { if !binary && strings.EqualFold(path.Ext(name), ".xml") { var err error data, err = pruneCreatedPageReferences(data, e.removedPages) if err != nil { return err } } return output(name, data, binary) } } fonts, images, spaces, definitions := e.usedResources() writeXML := func(name string, encode func(*ofdXML)) error { data, err := encodeOFDXML(encode) if err != nil { return err } if name == "Doc_0/Document.xml" { data, err = e.withOutlines(data) if err != nil { return err } } return write(name, data, 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(fonts)+len(images)+len(spaces) != 0 { x.text("DocumentRes", "DocumentRes.xml") } for _, name := range definitions { x.text("DocumentRes", "/"+name) } x.end("CommonData") x.start("Pages", nil) for _, page := range e.pages { var attrs ofdAttrs attrs.add("ID", page.ID) attrs.add("BaseLoc", packagePagePath("", page.ID)) x.start("Page", attrs) x.end("Page") } x.end("Pages") x.end("Document") }); err != nil { return err } if len(fonts)+len(images)+len(spaces) != 0 { if err := writeXML("Doc_0/DocumentRes.xml", func(x *ofdXML) { x.resources(fonts, images, spaces) }); err != nil { return err } } for i, page := range e.pages { if err := progress.report("pages", i, len(e.pages)); err != nil { return err } 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 } } else { data, err := e.sourceNewPageXML(page) if err != nil { return err } if err := write(name, data, false); err != nil { return err } } } if err := progress.report("pages", len(e.pages), len(e.pages)); err != nil { return err } for _, resources := range [][]editorResource{fonts, images} { for _, resource := range resources { if err := write(resource.name, resource.data, resource.image != nil); err != nil { return err } } } for _, resource := range e.resources { if resource.definition() != "" && slices.Contains(definitions, resource.name) { if err := write(resource.name, resource.data, false); err != nil { return err } } } return nil } // usedResources 筛选当前引用的新增资源及需要跨页复用的原资源文件,不修改资源池 // 返回: []editorResource 新增字体, []editorResource 新增图片, []ColorSpace 新增颜色空间, []string 原资源文件 func (e *Editor) usedResources() (fonts, images []editorResource, spaces []ColorSpace, sourceFiles []string) { used := make(map[string]bool) promoted := make(map[string]bool) files := make(map[string]bool) if e.source != nil { for _, name := range e.annotationFiles() { data, changed := e.source.reader.files[name] if !changed { continue } refs := editorResourceRefs{ids: used, files: make(map[string]bool)} if _, safe := refs.scan(bytes.NewReader(data), name); !safe { for _, resource := range e.resources { if resource.font != nil { used[resource.font.ID] = true } if resource.image != nil { used[resource.image.ID] = true } if resource.space != nil { used[resource.space.ID] = true } used[resource.definition()] = true } } } maps.Copy(promoted, used) } for _, page := range e.pages { var original map[string]GraphicObject if e.source != nil { original = make(map[string]GraphicObject) if source := e.source.pages[page.ID]; source != nil && source.original != nil { for _, layer := range source.original.Content.Layer { for _, object := range layer.Objects { original[editorObjectID(object)] = object } } } } for _, layer := range page.Content.Layer { for _, object := range layer.Objects { before, exists := original[editorObjectID(object)] collectObjectReferences(object, used) if e.source != nil && (!exists || !reflect.DeepEqual(before, object)) { collectObjectReferences(object, promoted) } if origin := e.objectOrigin(editorObjectID(object)); origin != nil && origin.page != nil && (!exists || before.Type != object.Type || !reflect.DeepEqual(before.CompositeGraphicUnit, object.CompositeGraphicUnit)) { for _, name := range origin.page.original.PageRes { files[e.source.reader.ResPath(resolveResourcePath(origin.page.ref.BaseLoc, "", name))] = true } } } } } for i := len(e.resources) - 1; i >= 0; i-- { resource := e.resources[i] if resource.definition() != "" && used[resource.definition()] { for _, id := range resource.references { used[id] = true if e.source != nil { promoted[id] = true } } } } for _, resource := range e.resources { if resource.definition() != "" && used[resource.definition()] { files[resource.name] = true } if resource.font != nil && used[resource.font.ID] { fonts = append(fonts, resource) delete(promoted, resource.font.ID) } if resource.image != nil && used[resource.image.ID] { images = append(images, resource) delete(promoted, resource.image.ID) } if resource.space != nil && used[resource.space.ID] { spaces = append(spaces, *resource.space) delete(promoted, resource.space.ID) } } for id := range promoted { if name := e.source.reader.resourceFiles[id]; name != "" { files[name] = true } } return fonts, images, spaces, slices.Sorted(maps.Keys(files)) } // page 写出页面尺寸、图层和对象 // 入参: page 页面内容 func (x *ofdXML) page(page PageContent) { 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 { x.layer(layer) } x.end("Content") x.end("Page") } // layer 写出新建图层及其对象 // 入参: layer 图层 func (x *ofdXML) layer(layer 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") } // resources 写出文档资源索引 // 入参: fonts 字体资源, images 图片资源, spaces 颜色空间 func (x *ofdXML) resources(fonts, images []editorResource, spaces []ColorSpace) { x.root("Res", ofdAttrs{{Name: xml.Name{Local: "BaseLoc"}, Value: "Res"}}) if len(spaces) != 0 { x.start("ColorSpaces", nil) for _, space := range spaces { x.start("ColorSpace", ofdAttrs{{Name: xml.Name{Local: "ID"}, Value: space.ID}, {Name: xml.Name{Local: "Type"}, Value: space.Type}, {Name: xml.Name{Local: "BitsPerComponent"}, Value: strconv.Itoa(space.BitsPerComponent)}}) x.end("ColorSpace") } x.end("ColorSpaces") } if len(fonts) != 0 { x.start("Fonts", nil) for _, resource := range fonts { 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(images) != 0 { x.start("MultiMedias", nil) for _, resource := range images { 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)) } } // encodeOFDXML 编码文档节点,用于保存和独立副本 // 入参: encode 节点写入方法 // 返回: []byte XML数据, error 错误信息 func encodeOFDXML(encode func(*ofdXML)) ([]byte, error) { var data bytes.Buffer x := newOFDXML(&data) encode(x) 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 var actions []Action if root { attrs.add("xmlns:ofd", ofdNamespace) } switch object.Type { case "TextObject": obj := object.TextObject attrs.add("ID", obj.ID) attrs.add("DrawParam", obj.DrawParam) 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("VScale", obj.VScale) attrs.add("Decoration", obj.Decoration) attrs.number("Weight", float64(obj.Weight)) attrs.number("ReadDirection", float64(obj.ReadDirection)) attrs.number("CharDirection", float64(obj.CharDirection)) attrs.number("LineWidth", obj.LineWidth) if obj.LineWidthSet && obj.LineWidth == 0 { attrs.add("LineWidth", "0") } 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) actions = obj.Actions case "PathObject", "Path": obj := object.PathObject attrs.add("ID", obj.ID) attrs.add("DrawParam", obj.DrawParam) attrs.add("Boundary", obj.Boundary) attrs.add("CTM", obj.CTM) attrs.number("LineWidth", obj.LineWidth) if obj.LineWidthSet && obj.LineWidth == 0 { attrs.add("LineWidth", "0") } attrs.number("MiterLimit", obj.MiterLimit) attrs.add("Join", obj.Join) attrs.add("Cap", obj.Cap) attrs.add("Rule", obj.Rule) if obj.dashPatternSet { attrs = append(attrs, xml.Attr{Name: xml.Name{Local: "DashPattern"}, Value: obj.DashPattern}) } else { 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) actions = obj.Actions 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) actions = obj.Actions case "CompositeObject", "CompositeGraphicUnit": obj := object.CompositeGraphicUnit attrs.add("ID", obj.ID) attrs.add("Boundary", obj.Boundary) attrs.add("CTM", obj.CTM) attrs.add("ResourceID", obj.ResourceID) attrs.add("DrawParam", obj.DrawParam) attrs.flag("Visible", obj.Visible) attrs.alpha(obj.Alpha) actions = obj.Actions } x.start(object.Type, attrs) x.actions(actions) if object.Type == "ImageObject" { x.clips(object.ImageObject.Clips) x.border(object.ImageObject.Border) } else if object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit" { x.clips(object.CompositeGraphicUnit.Clips) } else if object.Type == "PathObject" || object.Type == "Path" { x.clips(object.PathObject.Clips) x.color("StrokeColor", stroke) x.color("FillColor", fill) x.text("AbbreviatedData", object.PathObject.AbbreviatedData) } else if object.Type == "TextObject" { x.clips(object.TextObject.Clips) x.color("FillColor", fill) x.color("StrokeColor", stroke) offset, index := 0, 0 for _, code := range object.TextObject.TextCode { end := offset + len(textCodeRunes(code.Value)) for index < len(object.TextObject.CGTransform) && object.TextObject.CGTransform[index].CodePosition < end { transform := object.TextObject.CGTransform[index] attrs := ofdAttrs{ {Name: xml.Name{Local: "CodePosition"}, Value: strconv.Itoa(transform.CodePosition - offset)}, {Name: xml.Name{Local: "CodeCount"}, Value: strconv.Itoa(transform.CodeCount)}, {Name: xml.Name{Local: "GlyphCount"}, Value: strconv.Itoa(transform.GlyphCount)}, } x.start("CGTransform", attrs) x.text("Glyphs", transform.Glyphs) x.end("CGTransform") index++ } 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) offset = end } } x.end(object.Type) } // clips 写出对象路径裁剪,保留裁剪集合、区域及路径的变换 // 入参: clips 裁剪集合,nil不输出节点 func (x *ofdXML) clips(clips *Clips) { if clips == nil { return } var attrs ofdAttrs attrs.flag("TransFlag", clips.TransFlag) x.start("Clips", attrs) for _, clip := range clips.Clip { x.start("Clip", nil) for _, area := range clip.Area { var attrs ofdAttrs attrs.add("CTM", area.CTM) attrs.add("DrawParam", area.DrawParam) x.start("Area", attrs) for _, path := range area.Path { x.object(GraphicObject{Type: "Path", PathObject: path}, false) } x.end("Area") } x.end("Clip") } x.end("Clips") } // 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.add("ColorSpace", color.ColorSpace) if color.Index != nil { attrs.add("Index", strconv.Itoa(*color.Index)) } attrs.alpha(color.Alpha) x.start(name, attrs) if color.AxialShd != nil { shading := color.AxialShd x.shading("AxialShd", shadingAttrs(shading.MapType, shading.MapUnit, shading.Extend, shading.StartPoint, shading.EndPoint), shading.Segment) } if color.RadialShd != nil { shading := color.RadialShd attrs := shadingAttrs(shading.MapType, shading.MapUnit, shading.Extend, shading.StartPoint, shading.EndPoint) attrs.add("StartRadius", ofdNumber(shading.StartRadius)) attrs.add("EndRadius", ofdNumber(shading.EndRadius)) attrs.number("Eccentricity", shading.Eccentricity) attrs.number("Angle", shading.Angle) x.shading("RadialShd", attrs, shading.Segment) } if color.Pattern != nil { x.pattern(color.Pattern) } x.end(name) } // shadingAttrs 编码轴向和径向渐变的共同属性 // 入参: mapType 映射方式, mapUnit 映射周期, extend 延伸方式, start、end 渐变端点 // 返回: ofdAttrs 属性集合 func shadingAttrs(mapType string, mapUnit float64, extend, start, end string) ofdAttrs { var attrs ofdAttrs attrs.add("MapType", mapType) attrs.number("MapUnit", mapUnit) attrs.add("Extend", extend) attrs.add("StartPoint", start) attrs.add("EndPoint", end) return attrs } // shading 写出渐变分段,保留未显式设置的位置 // 入参: name 渐变类型, attrs 属性, segments 色标 func (x *ofdXML) shading(name string, attrs ofdAttrs, segments []ShdSegment) { x.start(name, attrs) for _, segment := range segments { var attrs ofdAttrs if !segment.positionMissing { attrs.add("Position", ofdNumber(segment.Position)) } x.start("Segment", attrs) x.element("Color", segment.Color) x.end("Segment") } x.end(name) } // element 写出具有标准XML标签的子树,显式声明OFD命名空间 // 入参: name 元素名称, value 元素内容 func (x *ofdXML) element(name string, value any) { if x.err == nil { x.err = x.encoder.EncodeElement(value, xml.StartElement{Name: xml.Name{Space: ofdNamespace, Local: name}}) } } // border 写出图片边框及其绘制颜色 // 入参: border 边框,nil不输出节点 func (x *ofdXML) border(border *ImageBorder) { if border == nil { return } var attrs ofdAttrs if border.LineWidth != nil { attrs.add("LineWidth", ofdNumber(*border.LineWidth)) } attrs.number("HorizonalCornerRadius", border.HorizonalCornerRadius) attrs.number("VerticalCornerRadius", border.VerticalCornerRadius) attrs.number("DashOffset", border.DashOffset) attrs.add("DashPattern", border.DashPattern) x.start("Border", attrs) x.color("BorderColor", (*FillColor)(border.BorderColor)) x.end("Border") } // pattern 写出图案单元,保留对象绘制顺序 // 入参: pattern 图案 func (x *ofdXML) pattern(pattern *Pattern) { var attrs ofdAttrs attrs.number("Width", pattern.Width) attrs.number("Height", pattern.Height) attrs.number("XStep", pattern.XStep) attrs.number("YStep", pattern.YStep) attrs.add("ReflectMethod", pattern.ReflectMethod) attrs.add("RelativeTo", pattern.RelativeTo) attrs.add("CTM", pattern.CTM) x.start("Pattern", attrs) x.start("CellContent", nil) for _, object := range pattern.CellContent.Objects { x.object(object, false) } x.end("CellContent") x.end("Pattern") } // actions 写出对象动作及精确点击区域,不检查链接可达性 // 入参: actions 动作列表 func (x *ofdXML) actions(actions []Action) { if len(actions) == 0 { return } x.start("Actions", nil) for _, action := range actions { var attrs ofdAttrs attrs.add("Event", action.Event) x.start("Action", attrs) if action.Region != nil { x.start("Region", nil) for _, area := range action.Region.Area { x.start("Area", ofdAttrs{{Name: xml.Name{Local: "Start"}, Value: area.Start}}) for _, command := range area.Command { var attrs ofdAttrs for _, pair := range [][2]string{{"Point1", command.Point1}, {"Point2", command.Point2}, {"Point3", command.Point3}, {"EllipseSize", command.EllipseSize}, {"RotationAngle", command.RotationAngle}, {"LargeArc", command.LargeArc}, {"SweepDirection", command.SweepDirection}, {"EndPoint", command.EndPoint}} { attrs.add(pair[0], pair[1]) } x.start(command.Type, attrs) x.end(command.Type) } x.end("Area") } x.end("Region") } if action.Goto != nil { x.start("Goto", nil) if dest := action.Goto.Dest; dest != nil { attrs := ofdAttrs{{Name: xml.Name{Local: "Type"}, Value: dest.Type}, {Name: xml.Name{Local: "PageID"}, Value: dest.PageID}} switch dest.Type { case "XYZ": attrs.add("Left", ofdNumber(dest.Left)) attrs.add("Top", ofdNumber(dest.Top)) attrs.add("Zoom", ofdNumber(dest.Zoom)) case "FitH": attrs.add("Top", ofdNumber(dest.Top)) case "FitV": attrs.add("Left", ofdNumber(dest.Left)) case "FitR": attrs.add("Left", ofdNumber(dest.Left)) attrs.add("Right", ofdNumber(dest.Right)) attrs.add("Top", ofdNumber(dest.Top)) attrs.add("Bottom", ofdNumber(dest.Bottom)) } x.start("Dest", attrs) x.end("Dest") } if action.Goto.Bookmark != nil { x.element("Bookmark", action.Goto.Bookmark) } x.end("Goto") } if action.URI != nil { x.element("URI", action.URI) } if action.GotoA != nil { x.element("GotoA", action.GotoA) } if action.Sound != nil { x.element("Sound", action.Sound) } if action.Movie != nil { x.element("Movie", action.Movie) } x.end("Action") } x.end("Actions") }