diff --git a/internal/base/font/ttf.go b/internal/base/font/ttf.go index c6dab1e..8fc3007 100644 --- a/internal/base/font/ttf.go +++ b/internal/base/font/ttf.go @@ -29,7 +29,7 @@ func NormalizeOS2(data []byte) []byte { var size int switch binary.BigEndian.Uint16(data) { case 0: - // 旧式68字节表保持原样,不将截断的78字节表推断为旧式表。 + // 旧式68字节表保持原样,不将截断的78字节表推断为旧式表 size = 78 case 1: size = 86 diff --git a/ofdgo_editor_batch.go b/ofdgo_editor_batch.go index 5037077..9cbc3d9 100644 --- a/ofdgo_editor_batch.go +++ b/ofdgo_editor_batch.go @@ -118,7 +118,7 @@ func (e *Editor) CopyObjectsContext(ctx context.Context, page int, objects []Gra return ids, nil } -// copyObjectsContext 复制标准对象及资源来源,由上层按需同步富文本标引。 +// copyObjectsContext 复制标准对象及资源来源,由上层按需同步富文本标引 // 入参: ctx 取消上下文, page 目标页, objects 对象快照, dx 横向位移, dy 纵向位移 // 返回: []string 新对象标识, error 校验或取消错误 func (e *Editor) copyObjectsContext(ctx context.Context, page int, objects []GraphicObject, dx, dy float64) ([]string, error) { @@ -529,7 +529,7 @@ func (e *Editor) DeleteObjects(page int, ids []string) error { }) } -// deleteObjects 删除已选择图元,由上层同步必要的富文本标引。 +// deleteObjects 删除已选择图元,由上层同步必要的富文本标引 // 入参: page 页面索引, ids 对象标识 // 返回: error 校验或删除错误 func (e *Editor) deleteObjects(page int, ids []string) error { diff --git a/ofdgo_editor_bookmark.go b/ofdgo_editor_bookmark.go index 93770ee..b1bc7a6 100644 --- a/ofdgo_editor_bookmark.go +++ b/ofdgo_editor_bookmark.go @@ -25,8 +25,8 @@ import ( "unicode/utf8" ) -// Bookmarks 按声明顺序读取当前文档版本的命名书签,返回独立副本,不读取跳转引用。 -// 返回: []Bookmark 书签列表,无书签时为nil;error 读取、结构或名称重复错误。 +// Bookmarks 按声明顺序读取当前文档版本的命名书签,返回独立副本,不读取跳转引用 +// 返回: []Bookmark 书签列表,无书签时为nil;error 读取、结构或名称重复错误 func (e *Editor) Bookmarks() ([]Bookmark, error) { if e.source == nil { return nil, nil @@ -42,30 +42,30 @@ func (e *Editor) Bookmarks() ([]Bookmark, error) { return editorBookmarks(data, root) } -// AddBookmark 在当前文档版本末尾添加名称唯一的书签,保留扩展内容并记录一次撤销。 -// 入参: name 非空且区分大小写的名称,不自动裁剪空白;dest 目标,PageID为当前页面标识而非页序号。 -// 返回: error 名称、目标、页面或提交错误。 +// AddBookmark 在当前文档版本末尾添加名称唯一的书签,保留扩展内容并记录一次撤销 +// 入参: name 非空且区分大小写的名称,不自动裁剪空白;dest 目标,PageID为当前页面标识而非页序号 +// 返回: error 名称、目标、页面或提交错误 func (e *Editor) AddBookmark(name string, dest Dest) error { return e.editBookmark("", &Bookmark{Name: name, Dest: dest}, true) } -// UpdateBookmark 按原名称更新书签,重命名同步当前文档全部标准跳转引用,整体可撤销。 -// 入参: name 原名称;next 完整的新书签,Dest.PageID为当前页面标识;名称不变时仅修改目标。 -// 返回: error 名称不存在、重名、目标无效、引用读取或提交错误;内容未变时不记录撤销。 +// UpdateBookmark 按原名称更新书签,重命名同步当前文档全部标准跳转引用,整体可撤销 +// 入参: name 原名称;next 完整的新书签,Dest.PageID为当前页面标识;名称不变时仅修改目标 +// 返回: error 名称不存在、重名、目标无效、引用读取或提交错误;内容未变时不记录撤销 func (e *Editor) UpdateBookmark(name string, next Bookmark) error { return e.editBookmark(name, &next, false) } -// DeleteBookmark 删除当前文档版本的命名书签,存在标准跳转引用时拒绝删除。 -// 入参: name 书签名称;其他文档和历史版本的引用不阻止当前版本删除。 -// 返回: error 名称不存在、仍被引用、引用读取或提交错误。 +// DeleteBookmark 删除当前文档版本的命名书签,存在标准跳转引用时拒绝删除 +// 入参: name 书签名称;其他文档和历史版本的引用不阻止当前版本删除 +// 返回: error 名称不存在、仍被引用、引用读取或提交错误 func (e *Editor) DeleteBookmark(name string) error { return e.editBookmark(name, nil, false) } -// editorBookmarks 读取标准书签定义,拒绝重复集合、重名及缺失或重复目标,不解析扩展子树。 -// 入参: data 文档原文;root 文档根节点。 -// 返回: []Bookmark 独立定义列表;error 结构或解码错误。 +// editorBookmarks 读取标准书签定义,拒绝重复集合、重名及缺失或重复目标,不解析扩展子树 +// 入参: data 文档原文;root 文档根节点 +// 返回: []Bookmark 独立定义列表;error 结构或解码错误 func editorBookmarks(data []byte, root *editorXML) ([]Bookmark, error) { if root.childAt("Bookmarks", 1) != nil { return nil, fmt.Errorf("multiple bookmark collections") @@ -104,9 +104,9 @@ func editorBookmarks(data []byte, root *editorXML) ([]Bookmark, error) { return result, nil } -// validateBookmarkName 校验非空名称及XML可无损表示的Unicode字符。 -// 入参: name 名称。 -// 返回: error 名称错误。 +// validateBookmarkName 校验非空名称及XML可无损表示的Unicode字符 +// 入参: name 名称 +// 返回: error 名称错误 func validateBookmarkName(name string) error { if strings.TrimSpace(name) == "" || !utf8.ValidString(name) { return fmt.Errorf("bookmark name must be nonempty valid UTF-8") @@ -119,9 +119,9 @@ func validateBookmarkName(name string) error { return nil } -// validateBookmark 校验书签名称、标准目标参数及当前编辑页树中的目标标识。 -// 入参: bookmark 待写入定义。 -// 返回: error 名称、目标或页面错误。 +// validateBookmark 校验书签名称、标准目标参数及当前编辑页树中的目标标识 +// 入参: bookmark 待写入定义 +// 返回: error 名称、目标或页面错误 func (e *Editor) validateBookmark(bookmark Bookmark) error { if err := validateBookmarkName(bookmark.Name); err != nil { return err @@ -138,9 +138,9 @@ func (e *Editor) validateBookmark(bookmark Bookmark) error { return fmt.Errorf("bookmark destination page %q does not exist", bookmark.Dest.PageID) } -// editBookmark 校验定义后原子提交局部XML补丁,重命名使用包含未保存编辑的完整快照。 -// 入参: name 原名称;next 新定义,nil表示删除;add 是否新增。 -// 返回: error 校验、引用扫描或提交错误。 +// editBookmark 校验定义后原子提交局部XML补丁,重命名使用包含未保存编辑的完整快照 +// 入参: name 原名称;next 新定义,nil表示删除;add 是否新增 +// 返回: error 校验、引用扫描或提交错误 func (e *Editor) editBookmark(name string, next *Bookmark, add bool) error { if !add { if err := validateBookmarkName(name); err != nil { @@ -241,9 +241,9 @@ func (e *Editor) editBookmark(name string, next *Bookmark, add bool) error { }) } -// commitBookmarkSnapshot 接入重命名后的包,保留已加载对象的会话排版、裁剪及尚未使用的注册资源。 -// 入参: reader 已完成隔离和重命名的包快照。 -// 返回: error 快照重建或页面加载错误。 +// commitBookmarkSnapshot 接入重命名后的包,保留已加载对象的会话排版、裁剪及尚未使用的注册资源 +// 入参: reader 已完成隔离和重命名的包快照 +// 返回: error 快照重建或页面加载错误 func (e *Editor) commitBookmarkSnapshot(reader *Reader) error { before := e.transactionSnapshot() if err := e.commitResourceSnapshot(context.Background(), reader); err != nil { @@ -282,9 +282,9 @@ func (e *Editor) commitBookmarkSnapshot(reader *Reader) error { return nil } -// commitBookmarkParts 隔离修改后复用注解快照提交,保守复制路径不覆盖未知扩展可能引用的原文件。 -// 入参: base 原包快照;parts 当前入口修改集。 -// 返回: error 依赖解析、隔离或提交错误。 +// commitBookmarkParts 隔离修改后复用注解快照提交,保守复制路径不覆盖未知扩展可能引用的原文件 +// 入参: base 原包快照;parts 当前入口修改集 +// 返回: error 依赖解析、隔离或提交错误 func (e *Editor) commitBookmarkParts(base *editorSource, parts map[string][]byte) error { changes, err := base.reader.versionChanges(context.Background(), parts) if err != nil { @@ -303,9 +303,9 @@ func (e *Editor) commitBookmarkParts(base *editorSource, parts map[string][]byte return e.commitAnnotationParts(&source, nil) } -// editorBookmarkIsolate 在无法完整分析其他入口时保护所有已有文件,复制修改文件及其标准引用祖先。 -// 入参: reader 原包;changes 当前入口修改集。 -// 返回: map[string][]byte 隔离后的修改集;error 当前入口依赖或版本清单错误。 +// editorBookmarkIsolate 在无法完整分析其他入口时保护所有已有文件,复制修改文件及其标准引用祖先 +// 入参: reader 原包;changes 当前入口修改集 +// 返回: map[string][]byte 隔离后的修改集;error 当前入口依赖或版本清单错误 func editorBookmarkIsolate(reader *Reader, changes map[string][]byte) (map[string][]byte, error) { ctx := context.Background() entry, err := reader.DocumentRootContext(ctx) @@ -386,9 +386,9 @@ func editorBookmarkIsolate(reader *Reader, changes map[string][]byte) (map[strin return parts, nil } -// editorBookmarkDefinition 修改指定书签的标准字段,保留其扩展属性、扩展节点及其他定义原文。 -// 入参: data 文档原文;name 原名称;next 新定义,nil表示删除;add 是否新增。 -// 返回: []byte 修改后的文档;error 结构或编码错误。 +// editorBookmarkDefinition 修改指定书签的标准字段,保留其扩展属性、扩展节点及其他定义原文 +// 入参: data 文档原文;name 原名称;next 新定义,nil表示删除;add 是否新增 +// 返回: []byte 修改后的文档;error 结构或编码错误 func editorBookmarkDefinition(data []byte, name string, next *Bookmark, add bool) ([]byte, error) { root, err := parseEditorXML(data) if err != nil { @@ -497,9 +497,9 @@ func editorBookmarkDefinition(data []byte, name string, next *Bookmark, add bool return nil, fmt.Errorf("bookmark %q not found", name) } -// editorBookmarkReferences 扫描当前入口依赖中的标准书签跳转,拒绝带引用删除或生成重命名补丁。 -// 入参: reader 包含未保存修改的快照;name 原名称;next 新定义,nil表示删除。 -// 返回: map[string][]byte 需要修改的结构文件;error 读取、解析或被引用错误。 +// editorBookmarkReferences 扫描当前入口依赖中的标准书签跳转,拒绝带引用删除或生成重命名补丁 +// 入参: reader 包含未保存修改的快照;name 原名称;next 新定义,nil表示删除 +// 返回: map[string][]byte 需要修改的结构文件;error 读取、解析或被引用错误 func editorBookmarkReferences(reader *Reader, name string, next *Bookmark) (map[string][]byte, error) { _, structures, err := reader.versionDependencies(context.Background(), reader.documentRoot, nil, nil) if err != nil { @@ -522,9 +522,9 @@ func editorBookmarkReferences(reader *Reader, name string, next *Bookmark) (map[ return parts, nil } -// editorBookmarkReferenceXML 仅识别标准动作中的名称引用,保留扩展命名空间及不透明扩展负载。 -// 入参: data 结构原文;file 错误上下文中的文件名;name 原名称;next 新定义,nil表示检查删除。 -// 返回: []byte 修改后的原文;error 解析、属性编码或被引用错误。 +// editorBookmarkReferenceXML 仅识别标准动作中的名称引用,保留扩展命名空间及不透明扩展负载 +// 入参: data 结构原文;file 错误上下文中的文件名;name 原名称;next 新定义,nil表示检查删除 +// 返回: []byte 修改后的原文;error 解析、属性编码或被引用错误 func editorBookmarkReferenceXML(data []byte, file, name string, next *Bookmark) ([]byte, error) { root, err := parseEditorXML(data) if err != nil { diff --git a/ofdgo_editor_invoice.go b/ofdgo_editor_invoice.go new file mode 100644 index 0000000..0b59e18 --- /dev/null +++ b/ofdgo_editor_invoice.go @@ -0,0 +1,1034 @@ +// 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 ( + "context" + "encoding/json" + "fmt" + "path" + "slices" + "strconv" + "strings" +) + +// invoiceTagType 标识发票票面编辑关联,业务数据保持原规范格式 +const invoiceTagType = "OFDGo.Invoice" + +// invoiceNamespace 标识发票编辑关联的命名空间,版本仅随不兼容的语义变更升级 +const invoiceNamespace = "https://ofdgo.aite.me/ns/invoice/1" + +// InvoiceCreateOptions 指定结构化附件名称和可选票面,未指定票面时不新增页面 +type InvoiceCreateOptions struct { + Name string `json:"name,omitempty"` + Layout *InvoiceLayout `json:"layout,omitempty"` + Encoding *InvoiceEncodingOptions `json:"encoding,omitempty"` +} + +// InvoiceUpdateResult 保存写入后的来源、摘要及票面同步诊断,不代表发票真伪或签名验证结论 +type InvoiceUpdateResult struct { + Source InvoiceSource `json:"source"` + Digest string `json:"digest"` + Synchronized bool `json:"synchronized"` + Issues []InvoiceIssue `json:"issues,omitempty"` +} + +// invoiceBindingRecord 保存当前文档内的来源选择器,资源路径变化时仍以入口和摘要核对载荷 +type invoiceBindingRecord struct { + Kind string `json:"kind"` + ID string `json:"id"` + NodePath string `json:"nodePath,omitempty"` + Digest string `json:"digest"` + Invoice int `json:"invoice"` + Layout *InvoiceLayout `json:"layout,omitempty"` + Pages []invoiceOwnedPage `json:"pages,omitempty"` +} + +// invoiceOwnedPage 记录自动生成票面的标识和内容摘要,不覆盖后续人工修改 +type invoiceOwnedPage struct { + ID string `json:"id"` + Digest string `json:"digest"` +} + +// invoiceBindingEntry 保存标引索引及当前页序中的图元关联 +type invoiceBindingEntry struct { + index int + record invoiceBindingRecord + bindings []InvoiceBinding +} + +// Invoices 获取编辑状态的发票提取结果,不修改文档 +// 返回: InvoiceReport 发票及诊断, error 快照或解析错误 +func (e *Editor) Invoices() (InvoiceReport, error) { + return e.InvoicesContext(context.Background()) +} + +// InvoicesContext 获取当前文档及版本的编辑快照并提取发票 +// 入参: ctx 取消上下文 +// 返回: InvoiceReport 发票及诊断, error 快照或解析错误 +func (e *Editor) InvoicesContext(ctx context.Context) (InvoiceReport, error) { + if ctx == nil { + ctx = context.Background() + } + r, err := e.ReaderContext(ctx) + if err != nil { + return InvoiceReport{}, err + } + defer r.Close() + return r.InvoicesContext(ctx) +} + +// CreateInvoice 按指定业务规范创建结构化附件及可选票面,失败时整体回滚 +// 入参: ctx 取消上下文, invoice 业务字段, options 附件及票面选项 +// 返回: InvoiceSource 新增来源, error 校验、生成或提交错误 +func (e *Editor) CreateInvoice(ctx context.Context, invoice Invoice, options InvoiceCreateOptions) (InvoiceSource, error) { + if ctx == nil { + ctx = context.Background() + } + encoding := InvoiceEncodingOptions{} + if options.Encoding != nil { + encoding = *options.Encoding + } + data, err := EncodeInvoiceWithOptions(ctx, invoice, encoding) + if err != nil { + return InvoiceSource{}, err + } + return e.ImportInvoice(ctx, data, options) +} + +// ImportInvoice 导入结构化发票原文,可同时生成带字段关联的票面,不执行联网查验 +// 入参: ctx 取消上下文, data XML或XBRL原文, options 附件及票面选项 +// 返回: InvoiceSource 新增来源, error 格式、票面或提交错误 +func (e *Editor) ImportInvoice(ctx context.Context, data []byte, options InvoiceCreateOptions) (InvoiceSource, error) { + if ctx == nil { + ctx = context.Background() + } + report, err := invoiceWritableReport(ctx, data) + if err != nil { + return InvoiceSource{}, err + } + if options.Layout != nil && len(report.Invoices) != 1 { + return InvoiceSource{}, fmt.Errorf("invoice appearance requires one invoice per source") + } + name := options.Name + if name == "" { + name = "invoice.xml" + } + if ext := strings.ToLower(path.Ext(name)); ext != ".xml" && ext != ".xbrl" { + return InvoiceSource{}, fmt.Errorf("invoice attachment requires XML or XBRL extension") + } + var source InvoiceSource + err = e.Transaction(func(next *Editor) error { + var bindings []InvoiceBinding + if options.Layout != nil { + bindings, err = next.AppendInvoicePages(ctx, report.Invoices[0], *options.Layout) + if err != nil { + return err + } + } + id, err := next.AddAttachmentContext(ctx, name, data) + if err != nil { + return err + } + attachments, err := next.AttachmentsContext(ctx) + if err != nil { + return err + } + for _, attachment := range attachments { + if attachment.ID == id { + source = InvoiceSource{Kind: "attachment", ID: id, FileLoc: next.source.reader.ResPath(attachment.FileLoc)} + break + } + } + if source.FileLoc == "" { + return fmt.Errorf("created invoice attachment not found") + } + if len(bindings) > 0 { + if err := next.SetInvoiceBindings(ctx, source, 0, bindings); err != nil { + return err + } + if err := next.markInvoicePages(ctx, source, *options.Layout); err != nil { + return err + } + } + return ctx.Err() + }) + if err != nil { + return InvoiceSource{}, err + } + return source, nil +} + +// UpdateInvoice 原子替换指定来源并按显式关联更新票面,原始摘要用于拒绝过期修改 +// 入参: ctx 取消上下文, source 来源, digest 读取时摘要, data 修改后的原文, synchronize 是否同步已绑定图元 +// 返回: InvoiceUpdateResult 新来源及诊断, error 校验、绑定或提交错误 +func (e *Editor) UpdateInvoice(ctx context.Context, source InvoiceSource, digest string, data []byte, synchronize bool) (InvoiceUpdateResult, error) { + if ctx == nil { + ctx = context.Background() + } + report, err := invoiceWritableReport(ctx, data) + if err != nil { + return InvoiceUpdateResult{}, err + } + var result InvoiceUpdateResult + err = e.Transaction(func(next *Editor) error { + original, err := next.ExportInvoice(ctx, source) + if err != nil { + return err + } + previous, err := parseInvoiceXML(ctx, original, source) + if err != nil { + return err + } + payloadSigned, err := invoicePayloadSignature(ctx, original) + if err != nil { + return err + } + r, err := next.ReaderContext(ctx) + if err != nil { + return err + } + entries, err := r.invoiceBindings(ctx, source, digest, true) + r.Close() + if err != nil { + return err + } + if synchronize && len(entries) == 0 { + return fmt.Errorf("invoice has no appearance bindings") + } + updated, err := next.ReplaceInvoiceData(ctx, source, digest, data) + if err != nil { + return err + } + actual, err := next.ExportInvoice(ctx, updated) + if err != nil { + return err + } + newDigest := InvoiceDigest(actual) + for _, entry := range entries { + if entry.record.Invoice < 0 || entry.record.Invoice >= len(report.Invoices) { + return fmt.Errorf("bound invoice no longer exists") + } + entry.record.Kind, entry.record.ID, entry.record.NodePath = updated.Kind, updated.ID, updated.NodePath + entry.record.Digest = newDigest + if synchronize && entry.record.Layout != nil { + if err := next.rebuildInvoicePages(ctx, report.Invoices[entry.record.Invoice], entry); err != nil { + return err + } + continue + } + if synchronize { + if entry.record.Invoice >= len(previous.Invoices) { + return fmt.Errorf("bound invoice no longer exists") + } + if err := invoiceBindingCoverage(previous.Invoices[entry.record.Invoice], report.Invoices[entry.record.Invoice], entry.bindings); err != nil { + return err + } + if err := next.SyncInvoicePages(ctx, report.Invoices[entry.record.Invoice], entry.bindings); err != nil { + return err + } + } + encoded, err := json.Marshal(entry.record) + if err != nil { + return err + } + if err := next.UpdateStructureNode(entry.index, nil, StructureNode{Name: "Invoice", Namespace: invoiceNamespace, Text: string(encoded)}); err != nil { + return err + } + } + result = InvoiceUpdateResult{Source: updated, Digest: newDigest, Synchronized: synchronize} + if !synchronize { + result.Issues = append(result.Issues, InvoiceIssue{Code: "appearance-not-synchronized", Message: "invoice appearance was not synchronized", Source: updated}) + } + if next.source != nil { + signed, err := next.source.reader.hasOutputSignatures(ctx) + if err != nil { + return err + } + signed = signed || next.source.document.Signatures != "" || payloadSigned + if signed { + result.Issues = append(result.Issues, InvoiceIssue{Code: "signature-review-required", Message: "file contains signatures; verify them after editing", Source: updated}) + } + } + return ctx.Err() + }) + if err != nil { + return InvoiceUpdateResult{}, err + } + return result, nil +} + +// EditInvoice 定点修改原始字段并提交结构化数据及可选票面,不覆盖其他事实 +// 入参: ctx 取消上下文, source 来源, digest 读取时摘要, changes 字段修改, synchronize 是否同步票面 +// 返回: InvoiceUpdateResult 新来源及诊断, error 读取、修改或提交错误 +func (e *Editor) EditInvoice(ctx context.Context, source InvoiceSource, digest string, changes []InvoiceFieldChange, synchronize bool) (InvoiceUpdateResult, error) { + if ctx == nil { + ctx = context.Background() + } + data, err := e.ExportInvoice(ctx, source) + if err != nil { + return InvoiceUpdateResult{}, err + } + if InvoiceDigest(data) != digest { + return InvoiceUpdateResult{}, fmt.Errorf("invoice source changed") + } + changes = slices.Clone(changes) + for i := range changes { + change := &changes[i] + if change.Source.Kind != "" && change.Source.Kind != source.Kind || change.Source.ID != "" && change.Source.ID != source.ID || change.Source.FileLoc != "" && change.Source.FileLoc != source.FileLoc { + return InvoiceUpdateResult{}, fmt.Errorf("invoice field belongs to another source") + } + if change.Revision != "" && change.Revision != digest { + return InvoiceUpdateResult{}, fmt.Errorf("invoice field revision differs from source") + } + change.Revision = digest + if source.NodePath != "" { + if !strings.HasPrefix(change.Source.NodePath, source.NodePath+"/") { + return InvoiceUpdateResult{}, fmt.Errorf("invoice field is outside selected inline source") + } + change.Source.NodePath = strings.TrimPrefix(change.Source.NodePath, source.NodePath) + } + } + data, err = PatchInvoice(ctx, data, changes) + if err != nil { + return InvoiceUpdateResult{}, err + } + return e.UpdateInvoice(ctx, source, digest, data, synchronize) +} + +// SetInvoice 修改指定发票的业务字段和明细,保留原载荷中的未修改扩展 +// 入参: ctx 取消上下文, source 来源, digest 读取时摘要, index 发票索引, invoice 新业务值, synchronize 是否同步票面 +// 返回: InvoiceUpdateResult 新来源及诊断, error 读取、修改或提交错误 +func (e *Editor) SetInvoice(ctx context.Context, source InvoiceSource, digest string, index int, invoice Invoice, synchronize bool) (InvoiceUpdateResult, error) { + if ctx == nil { + ctx = context.Background() + } + data, err := e.ExportInvoice(ctx, source) + if err != nil { + return InvoiceUpdateResult{}, err + } + if InvoiceDigest(data) != digest { + return InvoiceUpdateResult{}, fmt.Errorf("invoice source changed") + } + data, err = UpdateInvoiceXML(ctx, data, index, invoice) + if err != nil { + return InvoiceUpdateResult{}, err + } + return e.UpdateInvoice(ctx, source, digest, data, synchronize) +} + +// DeleteInvoice 移除指定来源和对应编辑关联,保留票面及其他入口共享的业务原文 +// 入参: ctx 取消上下文, source 来源, digest 读取时摘要 +// 返回: error 来源、摘要或提交错误 +func (e *Editor) DeleteInvoice(ctx context.Context, source InvoiceSource, digest string) error { + if ctx == nil { + ctx = context.Background() + } + return e.Transaction(func(next *Editor) error { + r, err := next.ReaderContext(ctx) + if err != nil { + return err + } + entries, err := r.invoiceBindings(ctx, source, digest, false) + structures, structureErr := r.StructureContext(ctx) + r.Close() + if err != nil { + return err + } + if structureErr != nil { + return structureErr + } + if err := next.DeleteInvoiceData(ctx, source, digest); err != nil { + return err + } + for i := len(entries) - 1; i >= 0; i-- { + index := entries[i].index + if source.Kind == "tag" { + deleted, err := strconv.Atoi(source.ID) + if err != nil { + return err + } + if index > deleted { + index-- + } + } + if err := next.DeleteStructure(index); err != nil { + return err + } + } + removed := make([]int, 0, len(entries)+1) + for _, entry := range entries { + removed = append(removed, entry.index) + } + if source.Kind == "tag" { + index, err := strconv.Atoi(source.ID) + if err != nil { + return err + } + removed = append(removed, index) + } + if err := next.reindexInvoiceBindings(ctx, structures, removed, source); err != nil { + return err + } + return ctx.Err() + }) +} + +// reindexInvoiceBindings 在删除入口后维护其他发票的标引序号及内联子树序号 +// 入参: ctx 取消上下文, structures 删除前标引, removed 删除的标引索引, source 删除来源 +// 返回: error 元数据或提交错误 +func (e *Editor) reindexInvoiceBindings(ctx context.Context, structures []Structure, removed []int, source InvoiceSource) error { + for i, structure := range structures { + if err := ctx.Err(); err != nil { + return err + } + if slices.Contains(removed, i) || structure.TypeID != invoiceTagType || structure.Namespace != invoiceNamespace { + continue + } + if structure.Root == nil || structure.Error != "" { + return fmt.Errorf("invalid invoice appearance metadata") + } + root := *structure.Root + var record invoiceBindingRecord + if err := json.Unmarshal([]byte(root.Text), &record); err != nil { + return err + } + changed := false + if record.Kind == "tag" { + old, err := strconv.Atoi(record.ID) + if err != nil { + return err + } + index := old + for _, deleted := range removed { + if deleted < old { + index-- + } + } + if index != old { + record.ID, changed = strconv.Itoa(index), true + } + } + if source.Kind == "extension" && source.NodePath != "" && record.Kind == source.Kind && record.ID == source.ID { + position := strings.LastIndex(source.NodePath, "*") + if position < 0 { + return fmt.Errorf("invalid inline invoice source") + } + prefix := source.NodePath[:position] + if strings.HasPrefix(record.NodePath, prefix+"*[") { + deleted, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(source.NodePath, prefix+"*["), "]")) + if err != nil { + return err + } + index, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(record.NodePath, prefix+"*["), "]")) + if err != nil { + return err + } + if index > deleted { + record.NodePath, changed = fmt.Sprintf("%s*[%d]", prefix, index-1), true + } + } + } + if !changed { + continue + } + encoded, err := json.Marshal(record) + if err != nil { + return err + } + root.Text, root.Children, root.References = string(encoded), nil, nil + index := i + for _, deleted := range removed { + if deleted < i { + index-- + } + } + if err := e.UpdateStructureNode(index, nil, root); err != nil { + return err + } + } + return ctx.Err() +} + +// InvoiceBindings 读取当前来源指定发票的持久化票面关联,页码按当前页序解析 +// 入参: ctx 取消上下文, source 来源, index 来源内从0开始的发票索引 +// 返回: []InvoiceBinding 图元关联, error 来源、摘要或引用错误 +func (r *Reader) InvoiceBindings(ctx context.Context, source InvoiceSource, index int) ([]InvoiceBinding, error) { + if ctx == nil { + ctx = context.Background() + } + data, err := r.ExportInvoice(ctx, source) + if err != nil { + return nil, err + } + report, err := parseInvoiceXML(ctx, data, source) + if err != nil { + return nil, err + } + if index < 0 || index >= len(report.Invoices) { + return nil, fmt.Errorf("invoice index out of range") + } + entries, err := r.invoiceBindings(ctx, source, InvoiceDigest(data), true) + if err != nil { + return nil, err + } + for _, entry := range entries { + if entry.record.Invoice == index { + return entry.bindings, nil + } + } + return nil, nil +} + +// CheckInvoiceAppearance 核对显式绑定的可见字符,忽略换行差异,不推断未绑定字段或判断签名有效性 +// 入参: ctx 取消上下文, source 来源, index 来源内发票索引 +// 返回: []InvoiceIssue 未绑定、缺失图元或文字差异, error 来源或结构错误 +func (r *Reader) CheckInvoiceAppearance(ctx context.Context, source InvoiceSource, index int) ([]InvoiceIssue, error) { + if ctx == nil { + ctx = context.Background() + } + data, err := r.ExportInvoice(ctx, source) + if err != nil { + return nil, err + } + report, err := parseInvoiceXML(ctx, data, source) + if err != nil { + return nil, err + } + if index < 0 || index >= len(report.Invoices) { + return nil, fmt.Errorf("invoice index out of range") + } + bindings, err := r.InvoiceBindings(ctx, source, index) + if err != nil { + return nil, err + } + if len(bindings) == 0 { + return []InvoiceIssue{{Code: "appearance-unbound", Message: "invoice has no appearance bindings", Source: source}}, nil + } + editor, err := r.EditorContext(ctx) + if err != nil { + return nil, err + } + var issues []InvoiceIssue + for _, binding := range bindings { + if err := ctx.Err(); err != nil { + return nil, err + } + value, err := invoiceBindingValue(report.Invoices[index], binding) + if err != nil { + return nil, err + } + object, err := editor.Object(binding.Page, binding.ObjectID) + if err != nil || object.Type != "TextObject" { + issues = append(issues, InvoiceIssue{Code: "appearance-missing", Message: "bound text object is missing", Source: source, Field: binding.Field}) + continue + } + if !invoiceAppearanceMatches(object.TextObject, value) { + issues = append(issues, InvoiceIssue{Code: "appearance-mismatch", Message: "bound text differs from invoice data", Source: source, Field: binding.Field}) + } + } + return issues, nil +} + +// SetInvoiceBindings 设置指定发票的显式票面关联,空列表清除关联,不自动识别相同文字 +// 入参: ctx 取消上下文, source 来源, index 发票索引, bindings 字段与图元关联 +// 返回: error 来源、字段、引用或提交错误 +func (e *Editor) SetInvoiceBindings(ctx context.Context, source InvoiceSource, index int, bindings []InvoiceBinding) error { + if ctx == nil { + ctx = context.Background() + } + return e.Transaction(func(next *Editor) error { + r, err := next.ReaderContext(ctx) + if err != nil { + return err + } + defer r.Close() + data, err := r.ExportInvoice(ctx, source) + if err != nil { + return err + } + report, err := parseInvoiceXML(ctx, data, source) + if err != nil { + return err + } + if index < 0 || index >= len(report.Invoices) { + return fmt.Errorf("invoice index out of range") + } + entries, err := r.invoiceBindings(ctx, source, InvoiceDigest(data), false) + if err != nil { + return err + } + record := invoiceBindingRecord{Kind: source.Kind, ID: source.ID, NodePath: source.NodePath, Digest: InvoiceDigest(data), Invoice: index} + seen := make(map[string]bool) + fields := make(map[string]bool) + for _, binding := range bindings { + if err := ctx.Err(); err != nil { + return err + } + if _, err := invoiceBindingValue(report.Invoices[index], binding); err != nil { + return err + } + page, err := next.Page(binding.Page) + if err != nil { + return err + } + object, err := next.Object(binding.Page, binding.ObjectID) + if err != nil { + return err + } + if object.Type != "TextObject" { + return fmt.Errorf("invoice binding requires a text object") + } + key := editorResourceID(page.ID) + "/" + editorResourceID(binding.ObjectID) + fieldKey := fmt.Sprintf("%s/%d", binding.Field, binding.Item) + if seen[key] || fields[fieldKey] { + return fmt.Errorf("duplicate invoice appearance target") + } + seen[key] = true + fields[fieldKey] = true + } + root, err := next.invoiceBindingRoot(record, bindings) + if err != nil { + return err + } + structures, err := r.StructureContext(ctx) + if err != nil { + return err + } + for i, structure := range structures { + if structure.TypeID != invoiceTagType || structure.Namespace != invoiceNamespace { + continue + } + own := false + for _, entry := range entries { + own = own || entry.index == i && entry.record.Invoice == index + } + if own { + continue + } + if structure.Error != "" || structure.Root == nil { + return fmt.Errorf("invalid invoice appearance metadata") + } + var check func(*StructureNode) error + check = func(node *StructureNode) error { + if err := ctx.Err(); err != nil { + return err + } + for _, ref := range node.References { + for _, id := range ref.ObjectIDs { + if seen[editorResourceID(ref.PageID)+"/"+editorResourceID(id)] { + return fmt.Errorf("text object is already bound to another invoice") + } + } + } + for j := range node.Children { + if err := check(&node.Children[j]); err != nil { + return err + } + } + return nil + } + if err := check(structure.Root); err != nil { + return err + } + } + for _, entry := range entries { + if entry.record.Invoice != index { + continue + } + if len(bindings) == 0 { + if err := next.DeleteStructure(entry.index); err != nil { + return err + } + return ctx.Err() + } + if err := next.editStructure(entry.index, func(_ []byte, _ *editorXML, _ CustomTag) ([]byte, error) { return encodeStructureNode(root) }); err != nil { + return err + } + return ctx.Err() + } + if len(bindings) == 0 { + return ctx.Err() + } + _, err = next.AddStructure(CustomTag{TypeID: invoiceTagType, NameSpace: invoiceNamespace}, root) + if err != nil { + return err + } + return ctx.Err() + }) +} + +// invoiceBindings 读取入口和摘要匹配的关联,拒绝重复记录及损坏引用 +// 入参: ctx 取消上下文, source 来源, digest 来源摘要, targets 是否解析图元引用 +// 返回: []invoiceBindingEntry 关联记录, error 读取或结构错误 +func (r *Reader) invoiceBindings(ctx context.Context, source InvoiceSource, digest string, targets bool) ([]invoiceBindingEntry, error) { + structures, err := r.StructureContext(ctx) + if err != nil { + return nil, err + } + doc, err := r.DocContext(ctx) + if err != nil { + return nil, err + } + pages := signaturePageNumbers(doc) + var entries []invoiceBindingEntry + seen := make(map[int]bool) + for i, structure := range structures { + if err := ctx.Err(); err != nil { + return nil, err + } + if structure.TypeID != invoiceTagType || structure.Namespace != invoiceNamespace { + continue + } + if structure.Error != "" || structure.Root == nil { + return nil, fmt.Errorf("invalid invoice appearance metadata") + } + root := structure.Root + if root.Name != "Invoice" || root.Namespace != invoiceNamespace { + return nil, fmt.Errorf("invalid invoice appearance root") + } + var record invoiceBindingRecord + if err := json.Unmarshal([]byte(root.Text), &record); err != nil { + return nil, fmt.Errorf("invalid invoice appearance selector: %w", err) + } + if record.Kind != source.Kind || record.ID != source.ID || record.NodePath != source.NodePath { + continue + } + if record.Digest != digest { + continue + } + if record.Invoice < 0 || seen[record.Invoice] { + return nil, fmt.Errorf("duplicate or invalid invoice appearance record") + } + seen[record.Invoice] = true + entry := invoiceBindingEntry{index: i, record: record} + if !targets { + entries = append(entries, entry) + continue + } + for _, field := range root.Children { + if field.Namespace != invoiceNamespace || field.Name != "Field" || len(field.Children) != 1 { + return nil, fmt.Errorf("invalid invoice appearance field") + } + binding := InvoiceBinding{Item: -1} + for _, attr := range field.Attributes { + if attr.Namespace != "" { + return nil, fmt.Errorf("invalid invoice appearance attribute") + } + switch attr.Name { + case "Name": + binding.Field = attr.Value + case "Item": + binding.Item, err = strconv.Atoi(attr.Value) + if err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("unknown invoice appearance attribute") + } + } + ref := field.Children[0] + if ref.Name != "ObjectRef" || ref.Namespace != invoiceNamespace || len(ref.References) != 1 || len(ref.References[0].ObjectIDs) != 1 { + return nil, fmt.Errorf("invalid invoice appearance reference") + } + page, _ := resourceValue(pages, ref.References[0].PageID) + if page == 0 { + return nil, fmt.Errorf("invoice appearance page not found") + } + binding.Page, binding.ObjectID = page-1, ref.References[0].ObjectIDs[0] + entry.bindings = append(entry.bindings, binding) + } + entries = append(entries, entry) + } + return entries, nil +} + +// invoiceWritableReport 检查待写入载荷包含可识别发票且没有解析诊断 +// 入参: ctx 取消上下文, data 结构化原文 +// 返回: InvoiceReport 发票结果, error 格式或校验错误 +func invoiceWritableReport(ctx context.Context, data []byte) (InvoiceReport, error) { + report, err := parseInvoiceXML(ctx, data, InvoiceSource{}) + if err != nil { + return InvoiceReport{}, err + } + if len(report.Invoices) == 0 { + return InvoiceReport{}, fmt.Errorf("no supported invoice data") + } + if len(report.Issues) > 0 { + return InvoiceReport{}, fmt.Errorf("invalid invoice data: %s", report.Issues[0].Message) + } + if err := invoiceValidateXMLWrite(ctx, nil, data); err != nil { + return InvoiceReport{}, err + } + return report, nil +} + +// invoicePayloadSignature 识别XML数字签名和官方税务签名段,不推断签名有效性 +// 入参: ctx 取消上下文, data 结构化原文 +// 返回: bool 是否存在签名声明, error XML或取消错误 +func invoicePayloadSignature(ctx context.Context, data []byte) (bool, error) { + root, err := invoiceXMLTree(ctx, data) + if err != nil { + return false, err + } + nodes := []*invoiceXMLNode{root} + for len(nodes) > 0 { + if err := ctx.Err(); err != nil { + return false, err + } + node := nodes[len(nodes)-1] + nodes = nodes[:len(nodes)-1] + if node.name.Space == "http://www.w3.org/2000/09/xmldsig#" && node.name.Local == "Signature" || root.name.Space == "" && root.name.Local == "EInvoice" && node.parent == root && node.name.Space == "" && node.name.Local == "TaxBureauSignature" { + return true, nil + } + nodes = append(nodes, node.children...) + } + return false, nil +} + +// invoiceBindingCoverage 拒绝将未关联的字段修改误报为已同步,不猜测缺失图元 +// 入参: before 原发票, after 新发票, bindings 显式关联 +// 返回: error 未绑定字段错误 +func invoiceBindingCoverage(before, after Invoice, bindings []InvoiceBinding) error { + type fieldKey struct { + field string + item int + } + old, values := make(map[fieldKey]string), make(map[fieldKey]string) + bound := make(map[fieldKey]bool) + for _, field := range invoiceLayoutFields(before) { + old[fieldKey{field.field, field.item}] = field.value + } + for _, field := range invoiceLayoutFields(after) { + values[fieldKey{field.field, field.item}] = field.value + } + for _, binding := range bindings { + bound[fieldKey{binding.Field, binding.Item}] = true + var oldFacts, newFacts []InvoiceFact + name := "" + if strings.HasPrefix(binding.Field, "facts.") { + oldFacts, newFacts, name = before.Facts, after.Facts, strings.TrimPrefix(binding.Field, "facts.") + } else if strings.HasPrefix(binding.Field, "items.fields.") { + if binding.Item < 0 || binding.Item >= len(before.Items) || binding.Item >= len(after.Items) { + return fmt.Errorf("bound invoice item changed") + } + oldFacts, newFacts, name = before.Items[binding.Item].Fields, after.Items[binding.Item].Fields, strings.TrimPrefix(binding.Field, "items.fields.") + } + if name != "" { + index, err := strconv.Atoi(name) + if err != nil || index < 0 || index >= len(oldFacts) || index >= len(newFacts) { + return fmt.Errorf("bound invoice fact changed") + } + a, b := oldFacts[index], newFacts[index] + if a.Name != b.Name || a.Namespace != b.Namespace || a.Context != b.Context || a.Unit != b.Unit { + return fmt.Errorf("bound invoice fact identity changed") + } + } + } + for key, value := range old { + if value != values[key] && !bound[key] { + return fmt.Errorf("changed invoice field %s has no appearance binding", key.field) + } + } + for key, value := range values { + if value != old[key] && !bound[key] { + return fmt.Errorf("changed invoice field %s has no appearance binding", key.field) + } + } + return nil +} + +// invoiceOwnedPages 计算编辑快照中指定页面的语义摘要,忽略XML序列化差异 +// 入参: ctx 取消上下文, ids 页面标识 +// 返回: []invoiceOwnedPage 按指定顺序排列的摘要, error 页面或读取错误 +func (e *Editor) invoiceOwnedPages(ctx context.Context, ids []string) ([]invoiceOwnedPage, error) { + r, err := e.ReaderContext(ctx) + if err != nil { + return nil, err + } + defer r.Close() + doc, err := r.DocContext(ctx) + if err != nil { + return nil, err + } + pages := signaturePageNumbers(doc) + result := make([]invoiceOwnedPage, 0, len(ids)) + for _, id := range ids { + if err := ctx.Err(); err != nil { + return nil, err + } + index, _ := resourceValue(pages, id) + if index == 0 { + return nil, fmt.Errorf("generated invoice page not found") + } + page, err := r.PageContentByIndexContext(ctx, index-1) + if err != nil { + return nil, err + } + annotations, err := r.AnnotationInfosByIndex(index - 1) + if err != nil { + return nil, err + } + if len(annotations) != 0 { + return nil, fmt.Errorf("generated invoice page has annotations; set explicit bindings before synchronizing") + } + data, err := json.Marshal(page) + if err != nil { + return nil, err + } + result = append(result, invoiceOwnedPage{ID: id, Digest: InvoiceDigest(data)}) + } + return result, nil +} + +// markInvoicePages 保存新建票面的排版选项和页面摘要,供明细增删后安全重排 +// 入参: ctx 取消上下文, source 新增来源, layout 排版选项 +// 返回: error 关联或提交错误 +func (e *Editor) markInvoicePages(ctx context.Context, source InvoiceSource, layout InvoiceLayout) error { + r, err := e.ReaderContext(ctx) + if err != nil { + return err + } + data, err := r.ExportInvoice(ctx, source) + if err != nil { + r.Close() + return err + } + entries, err := r.invoiceBindings(ctx, source, InvoiceDigest(data), true) + r.Close() + if err != nil { + return err + } + if len(entries) != 1 { + return fmt.Errorf("generated invoice requires one binding record") + } + entry := entries[0] + var ids []string + for _, binding := range entry.bindings { + page, err := e.Page(binding.Page) + if err != nil { + return err + } + if !slices.Contains(ids, page.ID) { + ids = append(ids, page.ID) + } + } + entry.record.Layout = &layout + entry.record.Pages, err = e.invoiceOwnedPages(ctx, ids) + if err != nil { + return err + } + encoded, err := json.Marshal(entry.record) + if err != nil { + return err + } + return e.UpdateStructureNode(entry.index, nil, StructureNode{Name: "Invoice", Namespace: invoiceNamespace, Text: string(encoded)}) +} + +// rebuildInvoicePages 重排本库生成且未被修改的连续票面,保留前后无关页面及事务边界 +// 入参: ctx 取消上下文, invoice 新发票数据, entry 已更新来源及摘要的关联 +// 返回: error 页面变化、排版或提交错误 +func (e *Editor) rebuildInvoicePages(ctx context.Context, invoice Invoice, entry invoiceBindingEntry) error { + if len(entry.record.Pages) == 0 || entry.record.Layout == nil { + return fmt.Errorf("generated invoice page ownership is missing") + } + if e.source != nil && e.source.document.Signatures != "" { + return fmt.Errorf("signed invoice document requires explicit appearance bindings") + } + ids := make([]string, len(entry.record.Pages)) + indexes := make([]int, len(ids)) + for i, page := range entry.record.Pages { + ids[i], indexes[i] = page.ID, -1 + for j := range e.pages { + if sameResourceID(e.pages[j].ID, page.ID) { + indexes[i] = j + break + } + } + if indexes[i] < 0 || i > 0 && indexes[i] != indexes[i-1]+1 { + return fmt.Errorf("generated invoice pages were removed or reordered") + } + } + current, err := e.invoiceOwnedPages(ctx, ids) + if err != nil { + return err + } + if !slices.Equal(current, entry.record.Pages) { + return fmt.Errorf("generated invoice pages have been edited; set explicit bindings before synchronizing") + } + if err := invoiceCheckReflowReferences(ctx, e, ids, entry.index); err != nil { + return err + } + first := e.PageCount() + bindings, err := e.AppendInvoicePages(ctx, invoice, *entry.record.Layout) + if err != nil { + return err + } + var newIDs []string + for i := first; i < e.PageCount(); i++ { + newIDs = append(newIDs, e.pages[i].ID) + } + if err := e.DeletePages(indexes); err != nil { + return err + } + newIndexes := make([]int, len(newIDs)) + for i := range newIndexes { + newIndexes[i] = e.PageCount() - len(newIDs) + i + } + if err := e.MovePages(newIndexes, indexes[0]); err != nil { + return err + } + for i := range bindings { + bindings[i].Page = indexes[0] + bindings[i].Page - first + } + root, err := e.invoiceBindingRoot(entry.record, bindings) + if err != nil { + return err + } + if err := e.editStructure(entry.index, func(_ []byte, _ *editorXML, _ CustomTag) ([]byte, error) { return encodeStructureNode(root) }); err != nil { + return err + } + entry.record.Pages, err = e.invoiceOwnedPages(ctx, newIDs) + if err != nil { + return err + } + encoded, err := json.Marshal(entry.record) + if err != nil { + return err + } + if err := e.UpdateStructureNode(entry.index, nil, StructureNode{Name: "Invoice", Namespace: invoiceNamespace, Text: string(encoded)}); err != nil { + return err + } + return ctx.Err() +} + +// invoiceBindingRoot 将已校验字段关联编码为标准图元引用 +// 入参: record 来源及排版记录, bindings 当前页面关联 +// 返回: StructureNode 标引根节点, error 页面或编码错误 +func (e *Editor) invoiceBindingRoot(record invoiceBindingRecord, bindings []InvoiceBinding) (StructureNode, error) { + encoded, err := json.Marshal(record) + if err != nil { + return StructureNode{}, err + } + root := StructureNode{Name: "Invoice", Namespace: invoiceNamespace, Text: string(encoded)} + for _, binding := range bindings { + page, err := e.Page(binding.Page) + if err != nil { + return StructureNode{}, err + } + root.Children = append(root.Children, StructureNode{Name: "Field", Namespace: invoiceNamespace, Attributes: []StructureAttribute{{Name: "Name", Value: binding.Field}, {Name: "Item", Value: strconv.Itoa(binding.Item)}}, Children: []StructureNode{{Name: "ObjectRef", Namespace: invoiceNamespace, Attributes: []StructureAttribute{{Name: "PageRef", Value: page.ID}}, Text: binding.ObjectID}}}) + } + return root, nil +} diff --git a/ofdgo_editor_layer.go b/ofdgo_editor_layer.go index aefe2fb..753bf1d 100644 --- a/ofdgo_editor_layer.go +++ b/ofdgo_editor_layer.go @@ -23,8 +23,8 @@ import ( "strconv" ) -// LayerInfo 描述普通页面图层,Order为同类型零基顺序,ObjectCount包含页块中的图元。 -// ObjectIDs按层内文件顺序列出图元标识,包含PageBlock成员但不展开复合图元,空层返回空切片。 +// LayerInfo 描述普通页面图层,Order为同类型零基顺序,ObjectCount包含页块中的图元 +// ObjectIDs按层内文件顺序列出图元标识,包含PageBlock成员但不展开复合图元,空层返回空切片 type LayerInfo struct { ID string Type string @@ -34,7 +34,7 @@ type LayerInfo struct { ObjectIDs []string } -// Layers 按文件顺序返回普通页面图层,省略的类型规范化为Body,不包含模板图层。 +// Layers 按文件顺序返回普通页面图层,省略的类型规范化为Body,不包含模板图层 // 入参: page 零基页面索引 // 返回: []LayerInfo 独立图层清单, error 页面读取错误 func (e *Editor) Layers(page int) ([]LayerInfo, error) { @@ -45,7 +45,7 @@ func (e *Editor) Layers(page int) ([]LayerInfo, error) { return editorLayerInfos(p), nil } -// Layers 直接读取普通页面图层,不创建编辑器或分配新标识,返回值不持有页面可变数据。 +// Layers 直接读取普通页面图层,不创建编辑器或分配新标识,返回值不持有页面可变数据 // 入参: page 零基页面索引 // 返回: []LayerInfo 按文件顺序排列的图层, error 页面读取错误 func (r *Reader) Layers(page int) ([]LayerInfo, error) { @@ -56,7 +56,7 @@ func (r *Reader) Layers(page int) ([]LayerInfo, error) { return editorLayerInfos(p), nil } -// editorLayerInfos 将页面图层投影为不共享可变容器的管理清单。 +// editorLayerInfos 将页面图层投影为不共享可变容器的管理清单 // 入参: p 已读取的页面 // 返回: []LayerInfo 图层清单 func editorLayerInfos(p *PageContent) []LayerInfo { @@ -74,7 +74,7 @@ func editorLayerInfos(p *PageContent) []LayerInfo { return result } -// AddLayer 追加空图层并返回新标识,空类型采用Body,空DrawParam表示不设置继承参数。 +// AddLayer 追加空图层并返回新标识,空类型采用Body,空DrawParam表示不设置继承参数 // 入参: page 页面索引, layerType 图层类型, drawParam 绘制参数标识 // 返回: string 新图层标识, error 类型、资源或结构错误 func (e *Editor) AddLayer(page int, layerType, drawParam string) (string, error) { @@ -120,7 +120,7 @@ func (e *Editor) AddLayer(page int, layerType, drawParam string) (string, error) return id, nil } -// SetLayerType 设置图层类型,不改变图元坐标及绘制参数;同类型顺序由文件位置决定。 +// SetLayerType 设置图层类型,不改变图元坐标及绘制参数;同类型顺序由文件位置决定 // 入参: page 页面索引, id 图层标识, layerType 图层类型 // 返回: error 标识、类型或结构错误 func (e *Editor) SetLayerType(page int, id, layerType string) error { @@ -140,7 +140,7 @@ func (e *Editor) SetLayerType(page int, id, layerType string) error { }) } -// SetLayerDrawParam 设置图层绘制参数,空标识清除引用,图元局部覆盖保持不变。 +// SetLayerDrawParam 设置图层绘制参数,空标识清除引用,图元局部覆盖保持不变 // 入参: page 页面索引, id 图层标识, drawParam 绘制参数标识 // 返回: error 标识、资源或结构错误 func (e *Editor) SetLayerDrawParam(page int, id, drawParam string) error { @@ -159,7 +159,7 @@ func (e *Editor) SetLayerDrawParam(page int, id, drawParam string) error { }) } -// MoveLayer 调整图层在同类型图层中的零基顺序,保留其他类型及扩展节点的位置。 +// MoveLayer 调整图层在同类型图层中的零基顺序,保留其他类型及扩展节点的位置 // 入参: page 页面索引, id 图层标识, order 同类型目标顺序 // 返回: error 标识、顺序或结构错误 func (e *Editor) MoveLayer(page int, id string, order int) error { @@ -178,7 +178,7 @@ func (e *Editor) MoveLayer(page int, id string, order int) error { }) } -// DeleteLayer 删除空图层,含图元、页块或扩展子元素时拒绝;可先在Transaction中迁移图元。 +// DeleteLayer 删除空图层,含图元、页块或扩展子元素时拒绝;可先在Transaction中迁移图元 // 入参: page 页面索引, id 图层标识 // 返回: error 标识、非空图层或结构错误 func (e *Editor) DeleteLayer(page int, id string) error { @@ -201,8 +201,8 @@ func (e *Editor) DeleteLayer(page int, id string) error { }) } -// MoveObjectsToLayer 将普通页面选区按原绘制顺序追加到目标层,保持标识、坐标、扩展及有效样式。 -// 不接受模板或注解标识;页块成员须先通过已有复合编辑接口处理,失败不产生部分移动。 +// MoveObjectsToLayer 将普通页面选区按原绘制顺序追加到目标层,保持标识、坐标、扩展及有效样式 +// 不接受模板或注解标识;页块成员须先通过已有复合编辑接口处理,失败不产生部分移动 // 入参: page 页面索引, ids 不重复的普通图元标识, layerID 目标图层标识 // 返回: error 选区、继承参数或结构错误 func (e *Editor) MoveObjectsToLayer(page int, ids []string, layerID string) error { @@ -295,7 +295,7 @@ func (e *Editor) MoveObjectsToLayer(page int, ids []string, layerID string) erro }) } -// editorLayerType 将标准省略类型规范化,不改变已明确指定的类型。 +// editorLayerType 将标准省略类型规范化,不改变已明确指定的类型 // 入参: value 原类型 // 返回: string 有效类型 func editorLayerType(value string) string { @@ -305,14 +305,14 @@ func editorLayerType(value string) string { return value } -// validEditorLayerType 按正文表15校验可管理的图层类型。 +// validEditorLayerType 按正文表15校验可管理的图层类型 // 入参: value 类型值 // 返回: bool 是否为支持的标准类型 func validEditorLayerType(value string) bool { return value == "Body" || value == "Background" || value == "Foreground" } -// validateLayerDrawParam 在目标页面资源环境中核验完整绘制参数继承链。 +// validateLayerDrawParam 在目标页面资源环境中核验完整绘制参数继承链 // 入参: page 页面索引, id 绘制参数标识 // 返回: error 页面或资源错误 func (e *Editor) validateLayerDrawParam(page int, id string) error { @@ -323,7 +323,7 @@ func (e *Editor) validateLayerDrawParam(page int, id string) error { return err } -// editLayerPage 在完整包快照上编辑页面原文,事务失败回滚,提交时隔离共享页面及历史版本。 +// editLayerPage 在完整包快照上编辑页面原文,事务失败回滚,提交时隔离共享页面及历史版本 // 入参: page 页面索引, edit 页面原文修改回调 // 返回: error 读取、修改或提交错误 func (e *Editor) editLayerPage(page int, edit func(*Reader, []byte, *editorXML) ([]byte, error)) error { @@ -380,7 +380,7 @@ func (e *Editor) editLayerPage(page int, edit func(*Reader, []byte, *editorXML) }) } -// editorStructureChildren 仅枚举当前标准命名空间中的直接子元素。 +// editorStructureChildren 仅枚举当前标准命名空间中的直接子元素 // 入参: root 容器节点, kind 子元素名称 // 返回: []*editorXML 文件顺序节点清单 func editorStructureChildren(root *editorXML, kind string) []*editorXML { @@ -395,7 +395,7 @@ func editorStructureChildren(root *editorXML, kind string) []*editorXML { return nodes } -// editorFindStructureID 按数值标识查找唯一直接子元素,拒绝缺失及歧义标识。 +// editorFindStructureID 按数值标识查找唯一直接子元素,拒绝缺失及歧义标识 // 入参: root 容器节点, kind 元素名称, attribute 标识属性名, id 标识值 // 返回: *editorXML 唯一节点, error 标识错误 func editorFindStructureID(root *editorXML, kind, attribute, id string) (*editorXML, error) { @@ -417,7 +417,7 @@ func editorFindStructureID(root *editorXML, kind, attribute, id string) (*editor return found, nil } -// editorStructureAttribute 局部替换或删除无命名空间属性,保留扩展及其余原文。 +// editorStructureAttribute 局部替换或删除无命名空间属性,保留扩展及其余原文 // 入参: data 原文, node 目标节点, name 属性名, value 新值,空值删除属性 // 返回: []byte 更新后的完整原文, error XML错误 func editorStructureAttribute(data []byte, node *editorXML, name, value string) ([]byte, error) { @@ -432,7 +432,7 @@ func editorStructureAttribute(data []byte, node *editorXML, name, value string) return editorPatchXML(data, []editorXMLPatch{patch}), nil } -// editorAppendStructure 向普通或自闭合容器追加已绑定命名空间的元素。 +// editorAppendStructure 向普通或自闭合容器追加已绑定命名空间的元素 // 入参: data 原文, node 容器节点, fragment 待追加片段 // 返回: []byte 更新后的完整原文 func editorAppendStructure(data []byte, node *editorXML, fragment []byte) []byte { @@ -442,7 +442,7 @@ func editorAppendStructure(data []byte, node *editorXML, fragment []byte) []byte return editorPatchXML(data, []editorXMLPatch{{node.close, node.close, fragment}}) } -// editorMoveStructure 在指定同类节点位置间移动完整原文,不触及夹杂的扩展元素。 +// editorMoveStructure 在指定同类节点位置间移动完整原文,不触及夹杂的扩展元素 // 入参: data 原文, nodes 同类节点, node 被移动节点, order 目标零基顺序 // 返回: []byte 更新后的完整原文, error 顺序错误 func editorMoveStructure(data []byte, nodes []*editorXML, node *editorXML, order int) ([]byte, error) { @@ -463,7 +463,7 @@ func editorMoveStructure(data []byte, nodes []*editorXML, node *editorXML, order return editorPatchXML(data, patches), nil } -// commitLayerTemplateParts 持久化结构与已注册资源,隔离共享文件并恢复未变图元的会话编辑状态。 +// commitLayerTemplateParts 持久化结构与已注册资源,隔离共享文件并恢复未变图元的会话编辑状态 // 入参: reader 当前独立包快照, parts 文件修改集 // 返回: error 资源、隔离或提交错误 func (e *Editor) commitLayerTemplateParts(reader *Reader, parts map[string][]byte) error { diff --git a/ofdgo_editor_rich_text.go b/ofdgo_editor_rich_text.go index 83d4bed..aa0657c 100644 --- a/ofdgo_editor_rich_text.go +++ b/ofdgo_editor_rich_text.go @@ -28,13 +28,13 @@ import ( "strconv" ) -// richTextTagType 标识富文本编辑标引,正文使用标准复合资源和图元。 +// richTextTagType 标识富文本编辑标引,正文使用标准复合资源和图元 const richTextTagType = "OFDGo.RichText" -// richTextNamespace 标识富文本编辑语义的命名空间,版本仅随不兼容的语义变更升级。 +// richTextNamespace 标识富文本编辑语义的命名空间,版本仅随不兼容的语义变更升级 const richTextNamespace = "https://ofdgo.aite.me/ns/rich-text/1" -// richTextMetadata 不保存字体编号,以标准主文字对象序号恢复当前文档字体。 +// richTextMetadata 不保存字体编号,以标准主文字对象序号恢复当前文档字体 type richTextMetadata struct { Version int `json:"version"` Value RichText `json:"value"` @@ -45,14 +45,14 @@ type richTextMetadata struct { Digest string `json:"digest"` } -// richTextEntry 保存标引入口及延迟报告的损坏诊断。 +// richTextEntry 保存标引入口及延迟报告的损坏诊断 type richTextEntry struct { index int metadata richTextMetadata err error } -// richTextIndex 缓存当前文档修订的标引,不创建页面预览快照。 +// richTextIndex 缓存当前文档修订的标引,不创建页面预览快照 type richTextIndex struct { source *editorSource revision uint64 @@ -60,7 +60,7 @@ type richTextIndex struct { err error } -// AddRichText 原子添加标准组合及持久化编辑标引,成功时只产生一条历史。 +// AddRichText 原子添加标准组合及持久化编辑标引,成功时只产生一条历史 // 入参: page 页面索引, box 页面编辑框, value 富文本内容 // 返回: string 组合对象标识, error 排版、资源或持久化错误 func (e *Editor) AddRichText(page int, box Box, value RichText) (string, error) { @@ -88,15 +88,15 @@ func (e *Editor) AddRichText(page int, box Box, value RichText) (string, error) return id, nil } -// UpdateRichText 原子替换富文本内容,保留对象标识、局部编辑框及页面变换。 -// 安全普通文字显式重排后提升为组合;不修改共享的旧组合资源。 +// UpdateRichText 原子替换富文本内容,保留对象标识、局部编辑框及页面变换 +// 安全普通文字显式重排后提升为组合;不修改共享的旧组合资源 // 入参: page 页面索引, id 对象标识, value 完整富文本 // 返回: error 识别、排版或提交错误 func (e *Editor) UpdateRichText(page int, id string, value RichText) error { return e.updateRichTextFrame(page, id, value, 0, 0) } -// ResizeRichTextFrame 调整局部左边界与段落宽度并原子重排,保留页面方向和缩放。 +// ResizeRichTextFrame 调整局部左边界与段落宽度并原子重排,保留页面方向和缩放 // 入参: page 页面索引, id 对象标识, offset 局部横向偏移, width 新的局部段落宽度 // 返回: error 尺寸、排版或提交错误 func (e *Editor) ResizeRichTextFrame(page int, id string, offset, width float64) error { @@ -110,7 +110,7 @@ func (e *Editor) ResizeRichTextFrame(page int, id string, offset, width float64) return e.updateRichTextFrame(page, id, value, offset, width) } -// updateRichTextFrame 统一提交正文或编辑框变化,width为零时保持原始宽度。 +// updateRichTextFrame 统一提交正文或编辑框变化,width为零时保持原始宽度 // 入参: page 页面索引, id 对象标识, value 新内容, offset 局部偏移, width 可选宽度 // 返回: error 校验或提交错误 func (e *Editor) updateRichTextFrame(page int, id string, value RichText, offset, width float64) error { @@ -170,7 +170,7 @@ func (e *Editor) updateRichTextFrame(page int, id string, value RichText, offset }) } -// DeleteRichText 原子删除富文本及其标引,不删除其他实例引用的共享资源。 +// DeleteRichText 原子删除富文本及其标引,不删除其他实例引用的共享资源 // 入参: page 页面索引, id 对象标识 // 返回: error 识别或删除错误 func (e *Editor) DeleteRichText(page int, id string) error { @@ -195,8 +195,8 @@ func (e *Editor) DeleteRichText(page int, id string) error { }) } -// RichText 返回独立的富文本编辑值,未标引组合返回ErrNotRichText,损坏标引返回诊断。 -// 普通文字仅在定位和外观可以安全提升时返回单片段,否则明确拒绝。 +// RichText 返回独立的富文本编辑值,未标引组合返回ErrNotRichText,损坏标引返回诊断 +// 普通文字仅在定位和外观可以安全提升时返回单片段,否则明确拒绝 // 入参: page 页面索引, id 对象标识 // 返回: RichText 编辑内容, error 识别或持久化错误 func (e *Editor) RichText(page int, id string) (RichText, error) { @@ -238,7 +238,7 @@ func (e *Editor) RichText(page int, id string) (RichText, error) { return value, nil } -// RichTextFrame 返回原始局部编辑框及局部到页面的完整变换,不按组合缩放重新解释宽度。 +// RichTextFrame 返回原始局部编辑框及局部到页面的完整变换,不按组合缩放重新解释宽度 // 入参: page 页面索引, id 富文本或安全普通文字对象标识 // 返回: Box 局部编辑框, Matrix 页面变换, error 识别或几何错误 func (e *Editor) RichTextFrame(page int, id string) (Box, Matrix, error) { @@ -275,7 +275,7 @@ func (e *Editor) RichTextFrame(page int, id string) (Box, Matrix, error) { return entry.metadata.Frame, TranslationMatrix(box.X, box.Y).Multiply(NewMatrix(object.CompositeGraphicUnit.CTM)).Multiply(TranslationMatrix(entry.metadata.Offset[0], entry.metadata.Offset[1])), nil } -// prepareRichText 构造独立组合资源和不含字体编号的编辑标引。 +// prepareRichText 构造独立组合资源和不含字体编号的编辑标引 // 入参: box 页面或局部编辑框, value 富文本 // 返回: GraphicObject 标准组合, richTextMetadata 标引, error 准备错误 func (e *Editor) prepareRichText(box Box, value RichText) (GraphicObject, richTextMetadata, error) { @@ -322,7 +322,7 @@ func (e *Editor) prepareRichText(box Box, value RichText) (GraphicObject, richTe return object, meta, nil } -// richTextPlacement 根据局部逻辑坐标确定物理组合边界,负向装饰不会被资源裁剪。 +// richTextPlacement 根据局部逻辑坐标确定物理组合边界,负向装饰不会被资源裁剪 // 入参: meta 编辑框与资源尺寸, matrix 逻辑坐标到页面变换 // 返回: CompositeGraphicUnit 组合几何 func richTextPlacement(meta richTextMetadata, matrix Matrix) CompositeGraphicUnit { @@ -331,7 +331,7 @@ func richTextPlacement(meta richTextMetadata, matrix Matrix) CompositeGraphicUni return CompositeGraphicUnit{Boundary: editorBoxString(box), CTM: TranslationMatrix(-box.X, -box.Y).Multiply(matrix).String()} } -// richTextDigest 对已编码图元计算不含字体、颜色空间和字形编号的内容摘要。 +// richTextDigest 对已编码图元计算不含字体、颜色空间和字形编号的内容摘要 // 入参: objects 标准子对象 // 返回: string 稳定内容摘要 func richTextDigest(objects []GraphicObject) string { @@ -363,7 +363,7 @@ func richTextDigest(objects []GraphicObject) string { return hex.EncodeToString(hash[:]) } -// richTextEntry 从当前文档标引缓存定位对象,损坏项不降级为普通组合。 +// richTextEntry 从当前文档标引缓存定位对象,损坏项不降级为普通组合 // 入参: page 页面索引, id 组合标识 // 返回: richTextEntry 标引, error 缺失或损坏错误 func (e *Editor) richTextEntry(page int, id string) (richTextEntry, error) { @@ -381,7 +381,7 @@ func (e *Editor) richTextEntry(page int, id string) (richTextEntry, error) { return entry, nil } -// richTextMetadataIndex 按来源和修订缓存标引,仅读取标引文件,不生成Reader快照。 +// richTextMetadataIndex 按来源和修订缓存标引,仅读取标引文件,不生成Reader快照 // 返回: *richTextIndex 只读缓存 func (e *Editor) richTextMetadataIndex() *richTextIndex { if index := e.richIndex; index != nil && index.source == e.source && index.revision == e.revision { @@ -437,7 +437,7 @@ func (e *Editor) richTextMetadataIndex() *richTextIndex { return index } -// putRichTextMetadata 添加或替换带有标准标引引用的富文本语义。 +// putRichTextMetadata 添加或替换带有标准标引引用的富文本语义 // 入参: page 页面索引, id 对象标识, meta 编辑信息, index 原入口或-1 // 返回: error 编码或提交错误 func (e *Editor) putRichTextMetadata(page int, id string, meta richTextMetadata, index int) error { @@ -454,7 +454,7 @@ func (e *Editor) putRichTextMetadata(page int, id string, meta richTextMetadata, return err } -// promoteRichText 仅提升已有明确排版或可验证单行定位的普通文字。 +// promoteRichText 仅提升已有明确排版或可验证单行定位的普通文字 // 入参: page 页面索引, object 普通文字快照 // 返回: RichText 单片段, error 不安全提升错误 func (e *Editor) promoteRichText(page int, object GraphicObject) (RichText, error) { @@ -524,7 +524,7 @@ func (e *Editor) promoteRichText(page int, object GraphicObject) (RichText, erro return result, result.Validate() } -// richPlainOffset 保存可提升单行原文的基线偏移,不推断复杂定位布局。 +// richPlainOffset 保存可提升单行原文的基线偏移,不推断复杂定位布局 // 入参: text 已校验的普通文字 // 返回: float64 横向偏移, float64 基线偏移, error 无效坐标 func (e *Editor) richPlainOffset(text TextObject) (float64, float64, error) { @@ -553,7 +553,7 @@ func (e *Editor) richPlainOffset(text TextObject) (float64, float64, error) { return x, y - baseline, nil } -// copyRichTextMetadata 复制已识别组合的语义,使用目标标准子对象的字体引用。 +// copyRichTextMetadata 复制已识别组合的语义,使用目标标准子对象的字体引用 // 入参: source 来源编辑器, sourcePage 来源页, ids 来源标识, page 目标页, copied 目标标识 // 返回: error 元数据损坏或提交错误 func (e *Editor) copyRichTextMetadata(source *Editor, sourcePage int, ids []string, page int, copied []string) error { @@ -582,7 +582,7 @@ func (e *Editor) copyRichTextMetadata(source *Editor, sourcePage int, ids []stri return nil } -// richTextColors 为自产RGB颜色指定文档内有效颜色空间,不改变默认颜色空间。 +// richTextColors 为自产RGB颜色指定文档内有效颜色空间,不改变默认颜色空间 // 入参: object 排版生成的标准图元 // 返回: error 颜色或资源错误 func (e *Editor) richTextColors(object *GraphicObject) error { @@ -612,7 +612,7 @@ func (e *Editor) richTextColors(object *GraphicObject) error { return nil } -// copiedRichTextMetadata 检查当前文档对象快照的标引,不将外部同号对象误认成本地对象。 +// copiedRichTextMetadata 检查当前文档对象快照的标引,不将外部同号对象误认成本地对象 // 入参: objects 待复制快照 // 返回: map[int]richTextMetadata 需要复制的语义, error 损坏诊断 func (e *Editor) copiedRichTextMetadata(objects []GraphicObject) (map[int]richTextMetadata, error) { @@ -668,7 +668,7 @@ func (e *Editor) copiedRichTextMetadata(objects []GraphicObject) (map[int]richTe return result, nil } -// attachRichTextSnapshot 为普通对象快照附加不可变编辑语义,复制后不依赖原对象继续存在。 +// attachRichTextSnapshot 为普通对象快照附加不可变编辑语义,复制后不依赖原对象继续存在 // 入参: page 页面索引, object 对象快照 func (e *Editor) attachRichTextSnapshot(page int, object *GraphicObject) { object.richText = nil @@ -683,7 +683,7 @@ func (e *Editor) attachRichTextSnapshot(page int, object *GraphicObject) { object.richText = &meta } -// richTextTagsFontSafe 仅允许完整识别且不含资源编号的本库标引通过字体重绑定检查。 +// richTextTagsFontSafe 仅允许完整识别且不含资源编号的本库标引通过字体重绑定检查 // 入参: ctx 取消上下文, reader 包阅读器, index 文档索引 // 返回: bool 是否可以排除字体引用, error 读取或取消错误 func richTextTagsFontSafe(ctx context.Context, reader *Reader, index int) (bool, error) { diff --git a/ofdgo_editor_template.go b/ofdgo_editor_template.go index 3d9808d..d2bb70d 100644 --- a/ofdgo_editor_template.go +++ b/ofdgo_editor_template.go @@ -21,15 +21,15 @@ import ( "path" ) -// TemplateReference 普通页面的模板引用,保留原始模板标识及绑定层次。 -// PageIndex为当前文档的零基页面索引,PageID为页面标识;ZOrder为空表示沿用模板默认层次。 +// TemplateReference 普通页面的模板引用,保留原始模板标识及绑定层次 +// PageIndex为当前文档的零基页面索引,PageID为页面标识;ZOrder为空表示沿用模板默认层次 type TemplateReference struct { PageIndex int PageID string Template } -// Templates 返回当前文档及所选版本的模板定义副本,省略的ZOrder返回Background。 +// Templates 返回当前文档及所选版本的模板定义副本,省略的ZOrder返回Background // 返回: []TemplatePage 独立模板清单, error 文档读取错误 func (e *Editor) Templates() ([]TemplatePage, error) { if e.source == nil { @@ -42,7 +42,7 @@ func (e *Editor) Templates() ([]TemplatePage, error) { return result, nil } -// Templates 直接返回当前文档及版本的模板清单,不创建编辑器,省略的ZOrder返回Background。 +// Templates 直接返回当前文档及版本的模板清单,不创建编辑器,省略的ZOrder返回Background // 返回: []TemplatePage 独立模板定义副本, error 文档读取错误 func (r *Reader) Templates() ([]TemplatePage, error) { doc, err := r.Doc() @@ -56,7 +56,7 @@ func (r *Reader) Templates() ([]TemplatePage, error) { return result, nil } -// PageTemplates 按文件顺序返回普通页面模板引用,ZOrder为空表示沿用模板默认层次。 +// PageTemplates 按文件顺序返回普通页面模板引用,ZOrder为空表示沿用模板默认层次 // 入参: page 零基页面索引 // 返回: []Template 独立引用清单, error 页面读取错误 func (e *Editor) PageTemplates(page int) ([]Template, error) { @@ -67,8 +67,8 @@ func (e *Editor) PageTemplates(page int) ([]Template, error) { return append([]Template{}, p.Template...), nil } -// TemplateReferences 返回当前文档及所选版本全部普通页面的模板引用副本,不包含模板页自身引用。 -// 清单按页面索引及页内引用文件顺序排列,无引用时返回空切片,读取失败时不返回部分结果。 +// TemplateReferences 返回当前文档及所选版本全部普通页面的模板引用副本,不包含模板页自身引用 +// 清单按页面索引及页内引用文件顺序排列,无引用时返回空切片,读取失败时不返回部分结果 // 返回: []TemplateReference 引用定位及绑定层次清单, error 页面读取错误 func (e *Editor) TemplateReferences() ([]TemplateReference, error) { result := []TemplateReference{} @@ -84,8 +84,8 @@ func (e *Editor) TemplateReferences() ([]TemplateReference, error) { return result, nil } -// TemplateReferences 只读枚举当前文档及所选版本全部普通页面的模板引用,不创建编辑器或遍历其他文档、版本。 -// 返回独立副本,按页面索引及页内引用文件顺序排列;无引用时返回空切片,读取失败时不返回部分结果。 +// TemplateReferences 只读枚举当前文档及所选版本全部普通页面的模板引用,不创建编辑器或遍历其他文档、版本 +// 返回独立副本,按页面索引及页内引用文件顺序排列;无引用时返回空切片,读取失败时不返回部分结果 // 返回: []TemplateReference 引用定位及绑定层次清单, error 文档或页面读取错误 func (r *Reader) TemplateReferences() ([]TemplateReference, error) { doc, err := r.Doc() @@ -105,7 +105,7 @@ func (r *Reader) TemplateReferences() ([]TemplateReference, error) { return result, nil } -// BindTemplate 在页面末尾绑定模板,空ZOrder沿用模板默认层次,重复绑定拒绝。 +// BindTemplate 在页面末尾绑定模板,空ZOrder沿用模板默认层次,重复绑定拒绝 // 入参: page 页面索引, id 模板标识, zOrder 页面覆盖层次 // 返回: error 标识、层次或重复绑定错误 func (e *Editor) BindTemplate(page int, id, zOrder string) error { @@ -139,7 +139,7 @@ func (e *Editor) BindTemplate(page int, id, zOrder string) error { }) } -// UnbindTemplate 解除当前页面的唯一指定模板引用,保留模板定义与其他页面绑定。 +// UnbindTemplate 解除当前页面的唯一指定模板引用,保留模板定义与其他页面绑定 // 入参: page 页面索引, id 模板标识 // 返回: error 引用或结构错误 func (e *Editor) UnbindTemplate(page int, id string) error { @@ -152,7 +152,7 @@ func (e *Editor) UnbindTemplate(page int, id string) error { }) } -// SetPageTemplateZOrder 设置页面模板的前景或背景层次,空值删除覆盖以沿用模板默认值。 +// SetPageTemplateZOrder 设置页面模板的前景或背景层次,空值删除覆盖以沿用模板默认值 // 入参: page 页面索引, id 模板标识, zOrder 页面覆盖层次 // 返回: error 引用、层次或结构错误 func (e *Editor) SetPageTemplateZOrder(page int, id, zOrder string) error { @@ -171,7 +171,7 @@ func (e *Editor) SetPageTemplateZOrder(page int, id, zOrder string) error { }) } -// MovePageTemplate 调整引用在页面全部模板引用中的零基文件顺序,同层次引用按此顺序绘制。 +// MovePageTemplate 调整引用在页面全部模板引用中的零基文件顺序,同层次引用按此顺序绘制 // 入参: page 页面索引, id 模板标识, order 全部引用中的目标顺序 // 返回: error 引用、顺序或结构错误 func (e *Editor) MovePageTemplate(page int, id string, order int) error { @@ -184,9 +184,9 @@ func (e *Editor) MovePageTemplate(page int, id string, order int) error { }) } -// CreateTemplateFromPage 复制指定普通页面自身内容为模板,不改变源页或自动绑定,空层次采用Background。 -// 复用页面复制的标识重映射并保留原XML及资源;模板引用不递归复制,注解和签章不属于模板内容。 -// 模板编辑可使用普通页面副本及现有编辑接口,再通过ReplaceTemplateFromPage保持模板身份提交。 +// CreateTemplateFromPage 复制指定普通页面自身内容为模板,不改变源页或自动绑定,空层次采用Background +// 复用页面复制的标识重映射并保留原XML及资源;模板引用不递归复制,注解和签章不属于模板内容 +// 模板编辑可使用普通页面副本及现有编辑接口,再通过ReplaceTemplateFromPage保持模板身份提交 // 入参: page 源页面索引, name 模板名称, zOrder 默认前景背景层次 // 返回: string 新模板标识, error 页面、标识重映射或提交错误 func (e *Editor) CreateTemplateFromPage(page int, name, zOrder string) (string, error) { @@ -300,7 +300,7 @@ func (e *Editor) CreateTemplateFromPage(page int, name, zOrder string) (string, return id, nil } -// RenameTemplate 修改当前文档模板名称,空字符串清空名称,不修改共享模板内容。 +// RenameTemplate 修改当前文档模板名称,空字符串清空名称,不修改共享模板内容 // 入参: id 模板标识, name 模板名称 // 返回: error 标识或结构错误 func (e *Editor) RenameTemplate(id, name string) error { @@ -312,7 +312,7 @@ func (e *Editor) RenameTemplate(id, name string) error { }) } -// SetTemplateZOrder 设置模板默认前景背景层次,不修改页面显式覆盖,空值采用Background。 +// SetTemplateZOrder 设置模板默认前景背景层次,不修改页面显式覆盖,空值采用Background // 入参: id 模板标识, zOrder 默认前景背景层次 // 返回: error 标识、层次或结构错误 func (e *Editor) SetTemplateZOrder(id, zOrder string) error { @@ -328,8 +328,8 @@ func (e *Editor) SetTemplateZOrder(id, zOrder string) error { }) } -// ReplaceTemplateFromPage 复制普通页自身内容替换模板内容,保持模板标识、名称、层次及全部绑定。 -// 源页及其他文档和历史版本保持不变,整个替换仅产生一条撤销记录。 +// ReplaceTemplateFromPage 复制普通页自身内容替换模板内容,保持模板标识、名称、层次及全部绑定 +// 源页及其他文档和历史版本保持不变,整个替换仅产生一条撤销记录 // 入参: id 目标模板标识, page 普通源页面索引 // 返回: error 标识、复制或提交错误 func (e *Editor) ReplaceTemplateFromPage(id string, page int) error { @@ -382,7 +382,7 @@ func (e *Editor) ReplaceTemplateFromPage(id string, page int) error { }) } -// DeleteTemplate 删除当前文档未被普通页面引用的模板定义,保留可能被其他文档、版本或扩展引用的文件。 +// DeleteTemplate 删除当前文档未被普通页面引用的模板定义,保留可能被其他文档、版本或扩展引用的文件 // 入参: id 模板标识 // 返回: error 标识、被引用或结构错误 func (e *Editor) DeleteTemplate(id string) error { @@ -406,7 +406,7 @@ func (e *Editor) DeleteTemplate(id string) error { }) } -// editorTemplateOrder 将省略的模板默认层次规范化为Background。 +// editorTemplateOrder 将省略的模板默认层次规范化为Background // 入参: value 原始层次 // 返回: string 有效默认层次 func editorTemplateOrder(value string) string { @@ -416,14 +416,14 @@ func editorTemplateOrder(value string) string { return value } -// validEditorTemplateOrder 校验模板前景背景枚举及未指定状态。 +// validEditorTemplateOrder 校验模板前景背景枚举及未指定状态 // 入参: value 层次值 // 返回: bool 是否有效 func validEditorTemplateOrder(value string) bool { return value == "" || value == "Background" || value == "Foreground" } -// editorTemplateDefinition 查找当前阅读器中的唯一模板定义,拒绝缺失及歧义标识。 +// editorTemplateDefinition 查找当前阅读器中的唯一模板定义,拒绝缺失及歧义标识 // 入参: reader 当前阅读器, id 模板标识 // 返回: TemplatePage 定义副本, error 文档或标识错误 func editorTemplateDefinition(reader *Reader, id string) (TemplatePage, error) { @@ -446,7 +446,7 @@ func editorTemplateDefinition(reader *Reader, id string) (TemplatePage, error) { return result, nil } -// editTemplateDefinition 在事务快照中局部修改模板声明,保留命名空间、扩展及其他版本。 +// editTemplateDefinition 在事务快照中局部修改模板声明,保留命名空间、扩展及其他版本 // 入参: id 模板标识, edit 文档原文修改回调 // 返回: error 读取、修改或提交错误 func (e *Editor) editTemplateDefinition(id string, edit func(*Reader, []byte, *editorXML) ([]byte, error)) error { diff --git a/ofdgo_invoice.go b/ofdgo_invoice.go index 49a9734..4e7a205 100644 --- a/ofdgo_invoice.go +++ b/ofdgo_invoice.go @@ -213,7 +213,7 @@ func (r *Reader) InvoicesContext(ctx context.Context) (InvoiceReport, error) { report.Issues = append(report.Issues, InvoiceIssue{Code: "read-error", Message: err.Error(), Source: InvoiceSource{Kind: "tag"}}) } for i, tag := range tags { - if tag.TypeID == richTextTagType && tag.NameSpace == richTextNamespace { + if tag.TypeID == richTextTagType && tag.NameSpace == richTextNamespace || tag.TypeID == invoiceTagType && tag.NameSpace == invoiceNamespace { continue } if err := read(InvoiceSource{Kind: "tag", ID: strconv.Itoa(i), FileLoc: tag.FileLoc}); err != nil { diff --git a/ofdgo_invoice_layout.go b/ofdgo_invoice_layout.go new file mode 100644 index 0000000..d976264 --- /dev/null +++ b/ofdgo_invoice_layout.go @@ -0,0 +1,583 @@ +// 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 ( + "context" + "fmt" + "maps" + "math" + "reflect" + "slices" + "strconv" + "strings" +) + +// InvoiceLayout 指定发票信息页的字体资源及毫米尺寸,不代表税务机关的固定票样 +// Font必填;其余零值依次使用4毫米字号、210毫米宽、297毫米高、15毫米页边距 +type InvoiceLayout struct { + Font string `json:"font"` + FontSize float64 `json:"fontSize"` + PageWidth float64 `json:"pageWidth"` + PageHeight float64 `json:"pageHeight"` + Margin float64 `json:"margin"` +} + +// InvoiceBinding 将一个完整字段绑定至当前文档的文字对象,页码及明细索引从0开始 +// Field使用number、buyer.name、items.name等规范字段名,非明细字段的Item必须为-1 +// 原始事实使用facts.N或items.fields.N,N为原始事实索引,不根据名称合并不同上下文 +type InvoiceBinding struct { + Field string `json:"field"` + Item int `json:"item"` + Page int `json:"page"` + ObjectID string `json:"objectId"` +} + +// invoiceLayoutField 保存票面字段及独立标签 +type invoiceLayoutField struct { + field, label, value string + item int +} + +// invoiceAppearanceMatches 比较票面Unicode字符序列,仅忽略回车与换行,保留全部空格 +// 不校验显式换行、软折行、位置或可见性,字形索引文本无法确定原文时返回false +// 入参: text 票面文字对象, value 结构化字段原文 +// 返回: bool 忽略回车与换行后的字符序列是否一致 +func invoiceAppearanceMatches(text TextObject, value string) bool { + var content strings.Builder + for _, code := range text.TextCode { + if code.Index != "" { + return false + } + content.WriteString(string(textCodeRunes(code.Value))) + } + actual := strings.ReplaceAll(strings.ReplaceAll(content.String(), "\r", ""), "\n", "") + expected := strings.ReplaceAll(strings.ReplaceAll(value, "\r", ""), "\n", "") + return actual == expected +} + +// invoiceLayoutFields 按稳定顺序展开公共字段及明细,不推算缺失值 +// 入参: invoice 发票数据 +// 返回: []invoiceLayoutField 字段列表 +func invoiceLayoutFields(invoice Invoice) []invoiceLayoutField { + fields := []invoiceLayoutField{ + {"kind", "票种", invoice.Kind, -1}, {"standard", "规范", invoice.Standard, -1}, + {"version", "版本", invoice.Version, -1}, {"number", "发票号码", invoice.Number, -1}, + {"code", "发票代码", invoice.Code, -1}, {"date", "开票日期", invoice.Date, -1}, + {"currency", "币种", invoice.Currency, -1}, {"status", "状态", invoice.Status, -1}, + } + for _, party := range []struct { + prefix, label string + value InvoiceParty + }{{"buyer", "购买方", invoice.Buyer}, {"seller", "销售方", invoice.Seller}} { + for _, field := range []invoiceLayoutField{{"name", "名称", party.value.Name, -1}, {"taxId", "税号", party.value.TaxID, -1}, {"address", "地址", party.value.Address, -1}, {"phone", "电话", party.value.Phone, -1}, {"bank", "开户行", party.value.Bank, -1}, {"account", "账号", party.value.Account, -1}} { + field.field, field.label = party.prefix+"."+field.field, party.label+field.label + fields = append(fields, field) + } + } + for i, item := range invoice.Items { + for _, field := range []invoiceLayoutField{{"name", "名称", item.Name, i}, {"specification", "规格", item.Specification, i}, {"unit", "单位", item.Unit, i}, {"quantity", "数量", item.Quantity, i}, {"unitPrice", "单价", item.UnitPrice, i}, {"amount", "金额", item.Amount, i}, {"taxRate", "税率", item.TaxRate, i}, {"taxAmount", "税额", item.TaxAmount, i}} { + field.field, field.label = "items."+field.field, fmt.Sprintf("明细%d %s", i+1, field.label) + fields = append(fields, field) + } + } + fields = append(fields, invoiceLayoutField{"amount", "不含税金额", invoice.Amount, -1}, invoiceLayoutField{"taxAmount", "税额", invoice.TaxAmount, -1}, invoiceLayoutField{"totalAmount", "价税合计", invoice.TotalAmount, -1}, invoiceLayoutField{"remarks", "备注", invoice.Remarks, -1}) + for i, fact := range invoice.Facts { + if invoiceLayoutFactVisible(invoice, fact) { + fields = append(fields, invoiceLayoutField{fmt.Sprintf("facts.%d", i), fact.Name, fact.Value, -1}) + } + } + for i, item := range invoice.Items { + for j, fact := range item.Fields { + duplicate := false + for _, existing := range invoice.Facts { + if reflect.DeepEqual(existing, fact) { + duplicate = true + break + } + } + if !duplicate && invoiceLayoutFactVisible(invoice, fact) { + fields = append(fields, invoiceLayoutField{fmt.Sprintf("items.fields.%d", j), fmt.Sprintf("明细%d %s", i+1, fact.Name), fact.Value, i}) + } + } + } + return fields +} + +// invoiceLayoutFactVisible 排除上下文、单位及已明确映射的相同字段值 +// 入参: invoice 发票数据, fact 原始业务事实 +// 返回: bool 是否需要单独显示 +func invoiceLayoutFactVisible(invoice Invoice, fact InvoiceFact) bool { + if fact.Nil || fact.Value == "" || fact.Namespace == invoiceXBRL && (fact.Name == "context" || fact.Name == "unit") { + return false + } + kind, _ := invoiceTaxonomy(fact.Namespace) + if kind != "" && kind == invoice.Kind { + if field := invoiceXMLField(&invoice, kind, fact.Name); field != nil && *field == fact.Value { + return false + } + for _, item := range invoice.Items { + if field := invoiceItemField(&item, kind, fact.Name); field != nil && *field == fact.Value { + return false + } + } + } + if fact.Namespace == "" { + var path strings.Builder + valid := true + for _, segment := range strings.Split(strings.TrimPrefix(fact.Source.NodePath, "/"), "/") { + name, index, ok := strings.Cut(segment, "[") + if !ok || !strings.HasPrefix(name, "{}") || !strings.HasSuffix(index, "]") { + valid = false + break + } + path.WriteByte('/') + path.WriteString(strings.TrimPrefix(name, "{}")) + } + if valid { + if path.String() == "/EInvoice/Header/Version" && fact.Value == invoice.Version { + return false + } + if field, _ := invoicePlainField(&invoice, nil, path.String()); field != nil && *field == fact.Value { + return false + } + for _, item := range invoice.Items { + if field, _ := invoicePlainField(&invoice, &item, path.String()); field != nil && *field == fact.Value { + return false + } + } + } + } + return true +} + +// invoiceBindingValue 读取明确字段与明细索引,不接受未知字段或隐式索引 +// 入参: invoice 发票数据, binding 字段绑定 +// 返回: string 字段原文, error 字段或索引错误 +func invoiceBindingValue(invoice Invoice, binding InvoiceBinding) (string, error) { + if strings.HasPrefix(binding.Field, "facts.") && binding.Item == -1 { + index, err := strconv.Atoi(strings.TrimPrefix(binding.Field, "facts.")) + if err == nil && index >= 0 && index < len(invoice.Facts) && binding.Field == fmt.Sprintf("facts.%d", index) { + return invoice.Facts[index].Value, nil + } + } + if strings.HasPrefix(binding.Field, "items.") { + if binding.Item >= 0 && binding.Item < len(invoice.Items) { + item := invoice.Items[binding.Item] + if strings.HasPrefix(binding.Field, "items.fields.") { + index, err := strconv.Atoi(strings.TrimPrefix(binding.Field, "items.fields.")) + if err == nil && index >= 0 && index < len(item.Fields) && binding.Field == fmt.Sprintf("items.fields.%d", index) { + return item.Fields[index].Value, nil + } + } + switch binding.Field { + case "items.name": + return item.Name, nil + case "items.specification": + return item.Specification, nil + case "items.unit": + return item.Unit, nil + case "items.quantity": + return item.Quantity, nil + case "items.unitPrice": + return item.UnitPrice, nil + case "items.amount": + return item.Amount, nil + case "items.taxRate": + return item.TaxRate, nil + case "items.taxAmount": + return item.TaxAmount, nil + } + } + } else if binding.Item == -1 { + switch binding.Field { + case "kind": + return invoice.Kind, nil + case "standard": + return invoice.Standard, nil + case "version": + return invoice.Version, nil + case "number": + return invoice.Number, nil + case "code": + return invoice.Code, nil + case "date": + return invoice.Date, nil + case "currency": + return invoice.Currency, nil + case "status": + return invoice.Status, nil + case "amount": + return invoice.Amount, nil + case "taxAmount": + return invoice.TaxAmount, nil + case "totalAmount": + return invoice.TotalAmount, nil + case "remarks": + return invoice.Remarks, nil + } + prefix, name, ok := strings.Cut(binding.Field, ".") + if ok && (prefix == "buyer" || prefix == "seller") { + party := invoice.Buyer + if prefix == "seller" { + party = invoice.Seller + } + switch name { + case "name": + return party.Name, nil + case "taxId": + return party.TaxID, nil + case "address": + return party.Address, nil + case "phone": + return party.Phone, nil + case "bank": + return party.Bank, nil + case "account": + return party.Account, nil + } + } + } + return "", fmt.Errorf("invalid invoice binding field %q item %d", binding.Field, binding.Item) +} + +// AppendInvoicePages 追加可搜索的发票信息页,字段内折行、字段间分页,不修改已有页面 +// 每字段对应一个完整文字对象,单字段超过页面容量时失败;绑定持久化由调用方处理 +// 入参: ctx 取消上下文, invoice 发票数据, options 字体与页面尺寸 +// 返回: []InvoiceBinding 新增字段绑定, error 排版或字体错误 +func (e *Editor) AppendInvoicePages(ctx context.Context, invoice Invoice, options InvoiceLayout) ([]InvoiceBinding, error) { + if e == nil || ctx == nil { + return nil, fmt.Errorf("editor and context are required") + } + if options.Font == "" { + return nil, fmt.Errorf("invoice font resource is required") + } + if options.FontSize == 0 { + options.FontSize = 4 + } + if options.PageWidth == 0 { + options.PageWidth = 210 + } + if options.PageHeight == 0 { + options.PageHeight = 297 + } + if options.Margin == 0 { + options.Margin = 15 + } + for _, value := range []float64{options.FontSize, options.PageWidth, options.PageHeight, options.Margin} { + if !finite(value) || value <= 0 { + return nil, fmt.Errorf("invoice layout dimensions must be positive and finite") + } + } + width, height := options.PageWidth-2*options.Margin, options.PageHeight-2*options.Margin + if width <= 0 || height <= 0 { + return nil, fmt.Errorf("invoice margins leave no page area") + } + var bindings []InvoiceBinding + err := e.Transaction(func(editor *Editor) error { + if err := ctx.Err(); err != nil { + return err + } + metrics, err := editor.editorFont(options.Font) + if err != nil { + return err + } + page, y := -1, options.Margin + for _, field := range invoiceLayoutFields(invoice) { + if err := ctx.Err(); err != nil { + return err + } + if field.value == "" { + continue + } + labelWidth := math.Min(width*.4, options.FontSize*8) + gap := options.FontSize + label := TextObject{Font: options.Font, Size: options.FontSize, Boundary: fmt.Sprintf("0 0 %s %s", ofdNumber(labelWidth), ofdNumber(height))} + value := TextObject{Font: options.Font, Size: options.FontSize, Boundary: fmt.Sprintf("0 0 %s %s", ofdNumber(width-labelWidth-gap), ofdNumber(height))} + labelHeight, err := invoiceLayoutText(&label, field.label, metrics) + if err != nil { + return fmt.Errorf("invoice label %s: %w", field.field, err) + } + valueHeight, err := invoiceLayoutText(&value, field.value, metrics) + if err != nil { + return fmt.Errorf("invoice field %s: %w", field.field, err) + } + rowHeight := math.Max(labelHeight, valueHeight) + if page < 0 || y+rowHeight > options.PageHeight-options.Margin { + page, err = editor.AddPage(options.PageWidth, options.PageHeight) + if err != nil { + return err + } + y = options.Margin + } + label.Boundary = fmt.Sprintf("%s %s %s %s", ofdNumber(options.Margin), ofdNumber(y), ofdNumber(labelWidth), ofdNumber(rowHeight)) + value.Boundary = fmt.Sprintf("%s %s %s %s", ofdNumber(options.Margin+labelWidth+gap), ofdNumber(y), ofdNumber(width-labelWidth-gap), ofdNumber(rowHeight)) + if _, err := editor.AddObject(page, GraphicObject{Type: "TextObject", TextObject: label}); err != nil { + return err + } + id, err := editor.AddObject(page, GraphicObject{Type: "TextObject", TextObject: value}) + if err != nil { + return err + } + bindings = append(bindings, InvoiceBinding{Field: field.field, Item: field.item, Page: page, ObjectID: id}) + y += rowHeight + options.FontSize*.5 + } + if len(bindings) == 0 { + return fmt.Errorf("invoice has no display fields") + } + return ctx.Err() + }) + if err != nil { + return nil, err + } + return bindings, nil +} + +// SyncInvoicePages 原子更新显式绑定的普通横向文字,不新增、移动或缩小对象 +// 不接受重复绑定、空文本、缺失对象及超出原框的内容;可嵌入上层事务与结构化数据一起提交 +// 入参: ctx 取消上下文, invoice 发票数据, bindings 当前文档字段绑定 +// 返回: error 绑定、字体或容量错误 +func (e *Editor) SyncInvoicePages(ctx context.Context, invoice Invoice, bindings []InvoiceBinding) error { + if e == nil || ctx == nil { + return fmt.Errorf("editor and context are required") + } + return e.Transaction(func(editor *Editor) error { + objects, fields := make(map[string]bool), make(map[string]bool) + for _, binding := range bindings { + if err := ctx.Err(); err != nil { + return err + } + objectKey := fmt.Sprintf("%d/%s", binding.Page, editorResourceID(binding.ObjectID)) + fieldKey := fmt.Sprintf("%d/%s", binding.Item, binding.Field) + if binding.ObjectID == "" || objects[objectKey] || fields[fieldKey] { + return fmt.Errorf("ambiguous invoice binding %q", binding.Field) + } + objects[objectKey], fields[fieldKey] = true, true + value, err := invoiceBindingValue(invoice, binding) + if err != nil { + return err + } + page, err := editor.page(binding.Page) + if err != nil { + return err + } + matches := 0 + for _, layer := range page.Content.Layer { + for _, candidate := range layer.Objects { + if sameResourceID(editorObjectID(candidate), binding.ObjectID) { + matches++ + } + } + } + if matches != 1 { + return fmt.Errorf("invoice binding %q requires one text object", binding.Field) + } + object, err := editor.Object(binding.Page, binding.ObjectID) + if err != nil { + return err + } + if object.Type != "TextObject" { + return fmt.Errorf("invoice binding %q is not text", binding.Field) + } + text := &object.TextObject + if text.CTM != "" || text.Clips != nil || text.Italic || text.Weight > 400 || text.VScale != 0 || text.Decoration != "" || text.DrawParam != "" || text.Stroke != nil && *text.Stroke || text.HScale != 0 && text.HScale != 1 { + return fmt.Errorf("invoice binding %q requires plain untransformed text", binding.Field) + } + metrics, err := editor.editorFont(text.Font) + if err != nil { + return err + } + if _, err := invoiceLayoutText(text, value, metrics); err != nil { + return fmt.Errorf("invoice field %s: %w", binding.Field, err) + } + if err := editor.UpdateObject(binding.Page, binding.ObjectID, object); err != nil { + return err + } + } + return ctx.Err() + }) +} + +// invoiceLayoutText 按实际字形及步进检查折行容量,并将负侧承移入原框 +// 入参: text 普通横向文字, value 字段原文, metrics 字体度量 +// 返回: float64 内容高度, error 缺字或越界错误 +func invoiceLayoutText(text *TextObject, value string, metrics FontMetrics) (float64, error) { + box, err := text.TextFrame() + if err != nil { + return 0, err + } + if value == "" { + return 0, fmt.Errorf("empty invoice field requires removing its binding and text object") + } + if strings.Trim(value, "\r\n") == "" { + return 0, fmt.Errorf("invoice text contains no display characters") + } + if err := LayoutText(text, value, TextLayout{Wrap: true}, metrics); err != nil { + return 0, err + } + outlines, ok := metrics.(FontOutlines) + if !ok { + return 0, fmt.Errorf("invoice layout requires glyph outlines") + } + unit := text.Size / float64(metrics.UnitsPerEm()) + _, descender, _ := metrics.VerticalMetrics() + left, top, right, bottom := 0.0, 0.0, 0.0, 0.0 + for _, code := range text.TextCode { + x, err := strconv.ParseFloat(code.X, 64) + if err != nil { + return 0, err + } + y, err := strconv.ParseFloat(code.Y, 64) + if err != nil { + return 0, err + } + bottom = math.Max(bottom, y+float64(descender)*unit) + for _, char := range textCodeRunes(code.Value) { + glyph := metrics.GlyphIndex(char) + var bounds Box + if provider, ok := metrics.(FontGlyphBounds); ok { + bounds, err = provider.GlyphBounds(glyph, text.Size) + } else { + var outline GeometryPath + outline, err = outlines.GlyphOutline(glyph, text.Size) + if err == nil { + bounds, err = outline.Bounds() + } + } + if err != nil { + return 0, err + } + left, top = math.Min(left, x+bounds.X), math.Min(top, y+bounds.Y) + right, bottom = math.Max(right, x+bounds.X+bounds.W), math.Max(bottom, y+bounds.Y+bounds.H) + x += float64(metrics.GlyphAdvance(glyph)) * unit + right = math.Max(right, x) + } + } + if right-left > box.W+1e-6 || bottom-top > box.H+1e-6 { + return 0, fmt.Errorf("invoice text exceeds binding frame") + } + for i := range text.TextCode { + x, _ := strconv.ParseFloat(text.TextCode[i].X, 64) + y, _ := strconv.ParseFloat(text.TextCode[i].Y, 64) + text.TextCode[i].X, text.TextCode[i].Y = ofdNumber(x-left), ofdNumber(y-top) + } + return math.Ceil((bottom-top)*1e6) / 1e6, nil +} + +// invoiceCheckReflowReferences 检查当前文档快照中指向待重排页的标准导航及已识别标引 +// 排除本次发票自身的标引,不推测未知扩展中的引用,也不读取其他文档或历史入口 +// 入参: ctx 取消上下文, e 当前编辑器, ownedPageIDs 待替换页面标识, ownStructureIndex 自有标引索引,-1表示无 +// 返回: error 引用冲突、结构读取或取消错误 +func invoiceCheckReflowReferences(ctx context.Context, e *Editor, ownedPageIDs []string, ownStructureIndex int) error { + if ctx == nil || e == nil { + return fmt.Errorf("editor and context are required") + } + if err := ctx.Err(); err != nil { + return err + } + owned := make(map[string]bool, len(ownedPageIDs)) + for _, id := range ownedPageIDs { + key := editorResourceID(id) + if key == "" { + return fmt.Errorf("invalid generated invoice page identifier") + } + owned[key] = true + } + reader, err := e.ReaderContext(ctx) + if err != nil { + return err + } + defer reader.Close() + structures, err := reader.StructureContext(ctx) + if err != nil { + return err + } + if ownStructureIndex < -1 || ownStructureIndex >= len(structures) { + return fmt.Errorf("invoice structure index out of range") + } + var checkStructure func(*StructureNode) error + checkStructure = func(node *StructureNode) error { + if err := ctx.Err(); err != nil { + return err + } + if node == nil { + return nil + } + for _, reference := range node.References { + if owned[editorResourceID(reference.PageID)] { + return fmt.Errorf("generated invoice page is referenced by another structure") + } + } + for i := range node.Children { + if err := checkStructure(&node.Children[i]); err != nil { + return err + } + } + return nil + } + for index, structure := range structures { + if index == ownStructureIndex { + continue + } + if structure.Error != "" { + return fmt.Errorf("cannot inspect invoice reflow references: %s", structure.Error) + } + if err := checkStructure(structure.Root); err != nil { + return err + } + } + _, files, err := reader.versionDependencies(ctx, reader.documentRoot, nil, nil) + if err != nil { + return err + } + var walk func(*editorXML) error + walk = func(node *editorXML) error { + if err := ctx.Err(); err != nil { + return err + } + if !node.matchesOFD(node.name.Local) { + return nil + } + if node.matchesOFD("Data") && node.parent != nil && node.parent.matchesOFD("Extension") { + return nil + } + if node.matchesOFD("Dest") && node.parent != nil && (node.parent.matchesOFD("Goto") || node.parent.matchesOFD("Bookmark") || node.parent.matchesOFD("OutlineElem")) && owned[editorResourceID(node.attr("PageID"))] { + return fmt.Errorf("generated invoice page is referenced by navigation") + } + if node.matchesOFD("Extension") && owned[editorResourceID(node.attr("RefId"))] { + return fmt.Errorf("generated invoice page is referenced by an extension") + } + for _, child := range node.children { + if err := walk(child); err != nil { + return err + } + } + return nil + } + for _, file := range slices.Sorted(maps.Keys(files)) { + data, err := reader.readFileViewContext(ctx, file) + if err != nil { + return err + } + root, err := parseEditorXMLContext(ctx, data) + if err != nil { + return err + } + if err := walk(root); err != nil { + return err + } + } + return ctx.Err() +} diff --git a/ofdgo_invoice_source.go b/ofdgo_invoice_source.go new file mode 100644 index 0000000..a243672 --- /dev/null +++ b/ofdgo_invoice_source.go @@ -0,0 +1,305 @@ +// 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" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/xml" + "fmt" + "strconv" + "strings" +) + +// InvoiceDigest 计算原始导出数据的SHA256摘要 +// 入参: data 导出数据 +// 返回: string 小写十六进制摘要 +func InvoiceDigest(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +// ExportInvoice 导出当前文档及版本声明的发票,内联内容补齐继承的命名空间 +// 相同选择器对应多个声明时拒绝操作,允许已识别的旧数据带解析诊断 +// 入参: ctx 取消上下文, source 提取结果中的精确来源 +// 返回: []byte 独立XML数据, error 来源或读取错误 +func (r *Reader) ExportInvoice(ctx context.Context, source InvoiceSource) ([]byte, error) { + if ctx == nil { + ctx = context.Background() + } + report, err := r.InvoicesContext(ctx) + if err != nil { + return nil, err + } + found := false + for _, invoice := range report.Invoices { + for _, candidate := range invoice.Sources { + found = found || candidate == source + } + } + if !found { + return nil, fmt.Errorf("invoice source is not declared in current document") + } + _, data, node, _, err := r.invoiceSourceNode(ctx, source) + if err != nil { + return nil, err + } + if source.NodePath != "" { + return editorXMLStandaloneContext(ctx, data[node.start:node.end], node) + } + payload, err := r.invoiceData(ctx, source.FileLoc) + if err != nil { + return nil, err + } + return bytes.Clone(payload), nil +} + +// ExportInvoice 导出编辑器当前文档及版本声明的发票数据 +// 入参: ctx 取消上下文, source 提取结果中的精确来源 +// 返回: []byte 独立XML数据, error 来源或读取错误 +func (e *Editor) ExportInvoice(ctx context.Context, source InvoiceSource) ([]byte, error) { + if e == nil || e.source == nil { + return nil, fmt.Errorf("invoice source not found") + } + return e.source.reader.ExportInvoice(ctx, source) +} + +// ReplaceInvoiceData 仅替换选中声明,独立资源写时复制,旧数据允许诊断而新数据必须无诊断 +// 不删除原签名,修改签名覆盖的内容后需重新验签 +// 入参: ctx 取消上下文, source 精确来源, expectedDigest 必填原数据摘要, data 新发票XML +// 返回: InvoiceSource 提交后的实际来源, error 校验、冲突或提交错误 +func (e *Editor) ReplaceInvoiceData(ctx context.Context, source InvoiceSource, expectedDigest string, data []byte) (InvoiceSource, error) { + return e.changeInvoiceSource(ctx, source, expectedDigest, data, false) +} + +// DeleteInvoiceData 仅删除选中来源声明或内联发票子树,不删除资源及其他别名 +// 不删除原签名,修改签名覆盖的内容后需重新验签 +// 入参: ctx 取消上下文, source 精确来源, expectedDigest 必填原数据摘要 +// 返回: error 校验、冲突或提交错误 +func (e *Editor) DeleteInvoiceData(ctx context.Context, source InvoiceSource, expectedDigest string) error { + _, err := e.changeInvoiceSource(ctx, source, expectedDigest, nil, true) + return err +} + +// changeInvoiceSource 校验原始摘要并原子修改声明,预检隔离后的来源供绑定重定向 +// 入参: ctx 取消上下文, source 来源, digest 原始摘要, data 替换数据, remove 删除声明 +// 返回: InvoiceSource 新来源, error 校验或提交错误 +func (e *Editor) changeInvoiceSource(ctx context.Context, source InvoiceSource, digest string, data []byte, remove bool) (InvoiceSource, error) { + if ctx == nil { + ctx = context.Background() + } + if digest == "" { + return InvoiceSource{}, fmt.Errorf("invoice digest is required") + } + old, err := e.ExportInvoice(ctx, source) + if err != nil { + return InvoiceSource{}, err + } + if InvoiceDigest(old) != digest { + return InvoiceSource{}, fmt.Errorf("invoice source has changed") + } + if !remove { + if len(data) > invoiceXMLLimit { + return InvoiceSource{}, fmt.Errorf("invoice data limit exceeded") + } + if _, err := invoiceWritableReport(ctx, data); err != nil { + return InvoiceSource{}, err + } + if err := invoiceValidateXMLWrite(ctx, old, data); err != nil { + return InvoiceSource{}, err + } + if bytes.Equal(old, data) { + return source, ctx.Err() + } + } + base := e.source + name, outer, selected, reference, err := base.reader.invoiceSourceNode(ctx, source) + if err != nil { + return InvoiceSource{}, err + } + parts := make(map[string][]byte) + patch := editorXMLPatch{start: selected.start, end: selected.end} + next := source + if !remove && source.NodePath != "" { + root, err := parseEditorXMLContext(ctx, data) + if err != nil { + return InvoiceSource{}, err + } + patch.data, err = editorXMLStandaloneContext(ctx, data[root.start:root.end], root) + if err != nil { + return InvoiceSource{}, err + } + } else if !remove { + file := e.packageName("Invoices/Invoice.xml") + var escaped bytes.Buffer + if err := xml.EscapeText(&escaped, []byte("/"+file)); err != nil { + return InvoiceSource{}, err + } + patch = editorXMLContent(outer, reference, escaped.Bytes()) + parts[file] = bytes.Clone(data) + next.FileLoc = file + } + parts[name] = editorPatchXML(outer, []editorXMLPatch{patch}) + if !remove && source.Kind == "attachment" { + root, err := parseEditorXMLContext(ctx, parts[name]) + if err != nil { + return InvoiceSource{}, err + } + if root.matchesOFD("Document") { + root = root.child("Attachments") + } + for _, child := range root.children { + if child.matchesOFD("Attachment") && child.attr("ID") == source.ID { + attribute, err := editorXMLAttributePatch(parts[name], child, "Size", ofdNumber(float64(len(data))/1024)) + if err != nil { + return InvoiceSource{}, err + } + parts[name] = editorPatchXML(parts[name], []editorXMLPatch{attribute}) + break + } + } + } + isolated, err := base.reader.versionChanges(ctx, parts) + if err != nil { + return InvoiceSource{}, err + } + files := make(map[string][]byte, len(base.reader.files)+len(isolated)) + for key, value := range base.reader.files { + files[key] = value + } + for key, value := range isolated { + files[key] = value + } + view := &Reader{Zip: base.reader.Zip, files: files, encryption: base.reader.encryption, documentIndex: base.reader.documentIndex, selectedVersion: base.reader.selectedVersion} + if err := view.initRootContext(ctx); err != nil { + return InvoiceSource{}, err + } + if !remove { + report, err := view.InvoicesContext(ctx) + if err != nil { + return InvoiceSource{}, err + } + found := false + for _, invoice := range report.Invoices { + for _, candidate := range invoice.Sources { + if candidate.Kind == next.Kind && candidate.ID == next.ID && candidate.NodePath == next.NodePath && (next.NodePath != "" || candidate.FileLoc == next.FileLoc) { + next, found = candidate, true + } + } + } + if !found { + return InvoiceSource{}, fmt.Errorf("replacement invoice source is not reachable") + } + } + if remove && source.Kind == "tag" { + index, err := strconv.Atoi(source.ID) + if err != nil { + return InvoiceSource{}, err + } + updated := *base + updated.structureStarts = make(map[int]map[string]int, len(base.structureStarts)) + for entry, starts := range base.structureStarts { + if entry < index { + updated.structureStarts[entry] = starts + } else if entry > index { + updated.structureStarts[entry-1] = starts + } + } + base = &updated + } + if err := e.commitAnnotationPartsContext(ctx, base, parts); err != nil { + return InvoiceSource{}, err + } + return next, nil +} + +// invoiceSourceNode 定位唯一声明及其引用节点,拒绝歧义别名,不接受任意包路径 +// 入参: ctx 取消上下文, source 已提取来源 +// 返回: string 声明文件, []byte 原文, *editorXML 选中声明, *editorXML 资源引用, error 定位错误 +func (r *Reader) invoiceSourceNode(ctx context.Context, source InvoiceSource) (string, []byte, *editorXML, *editorXML, error) { + doc, err := r.docStructureContext(ctx) + if err != nil { + return "", nil, nil, nil, err + } + container, entry, location := "", "", "" + switch source.Kind { + case "attachment": + container, entry, location = "Attachments", "Attachment", doc.Attachments.Path + case "tag": + container, entry, location = "CustomTags", "CustomTag", doc.CustomTags.Path + case "extension": + container, entry, location = "Extensions", "Extension", doc.Extensions.Path + default: + return "", nil, nil, nil, fmt.Errorf("invalid invoice source kind") + } + name := r.documentRoot + if strings.TrimSpace(location) != "" { + name = r.ResPath(location) + } + data, err := r.invoiceData(ctx, name) + if err != nil { + return "", nil, nil, nil, err + } + root, err := parseEditorXMLContext(ctx, data) + if err != nil { + return "", nil, nil, nil, err + } + if root.matchesOFD("Document") { + root = root.child(container) + } + var selected, reference *editorXML + count, index, declarations := 0, 0, 0 + if root != nil && root.matchesOFD(container) { + for _, child := range root.children { + if !child.matchesOFD(entry) { + continue + } + id := strconv.Itoa(index) + index++ + if source.Kind == "attachment" { + id = child.attr("ID") + } + if id != source.ID { + continue + } + declarations++ + dataIndex := 0 + for _, node := range child.children { + if source.NodePath != "" && source.Kind == "extension" && node.matchesOFD("Data") { + dataIndex++ + for k, payload := range node.children { + if source.NodePath == fmt.Sprintf("/Extensions/Extension[%d]/Data[%d]/*[%d]", index, dataIndex, k+1) && source.FileLoc == name { + selected, count = payload, count+1 + } + } + } else if source.NodePath == "" && (source.Kind == "extension" && node.matchesOFD("ExtendData") || source.Kind != "extension" && node.matchesOFD("FileLoc")) { + resolved := resolveResourcePath(name, "", strings.TrimSpace(editorImportText(data, node))) + if resolved == source.FileLoc { + selected, reference, count = child, node, count+1 + if source.Kind == "extension" { + selected = node + } + } + } + } + } + } + if count != 1 || declarations != 1 { + return "", nil, nil, nil, fmt.Errorf("invoice source declaration is missing or ambiguous") + } + return name, data, selected, reference, ctx.Err() +} diff --git a/ofdgo_invoice_write.go b/ofdgo_invoice_write.go new file mode 100644 index 0000000..946d53b --- /dev/null +++ b/ofdgo_invoice_write.go @@ -0,0 +1,1818 @@ +// 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" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/xml" + "fmt" + "io" + "math/big" + "reflect" + "slices" + "sort" + "strings" + "unicode/utf8" +) + +// InvoiceFormat 描述已适配格式,校验不代表完整XSD验证或发票验真 +type InvoiceFormat struct { + Kind string `json:"kind"` + Standard string `json:"standard"` + Version string `json:"version"` + Read bool `json:"read"` + Create bool `json:"create"` + Edit bool `json:"edit"` + Items bool `json:"items"` +} + +// InvoiceFieldChange 修改已有叶节点,Revision为源XML的SHA256十六进制摘要 +// 未提供Revision时必须提供ExpectedValue,ExpectedNil缺省表示原节点非空值 +type InvoiceFieldChange struct { + Source InvoiceSource `json:"source"` + Value string `json:"value"` + Nil bool `json:"nil,omitempty"` + Delete bool `json:"delete,omitempty"` + Revision string `json:"revision,omitempty"` + ExpectedValue *string `json:"expectedValue,omitempty"` + ExpectedNil *bool `json:"expectedNil,omitempty"` +} + +// InvoiceContext 保存调用方指定的单主体、无维度XBRL上下文 +type InvoiceContext struct { + ID string `json:"id"` + Identifier string `json:"identifier"` + Scheme string `json:"scheme"` + Instant string `json:"instant,omitempty"` + StartDate string `json:"startDate,omitempty"` + EndDate string `json:"endDate,omitempty"` +} + +// InvoiceUnit 保存单一度量的XBRL单位,不推测币种或数量单位 +type InvoiceUnit struct { + ID string `json:"id"` + Namespace string `json:"namespace"` + Name string `json:"name"` +} + +// InvoiceEncodingOptions 指定XBRL定义或官方XML片段,不加载外部资源或生成税务签名 +type InvoiceEncodingOptions struct { + SchemaRef string `json:"schemaRef"` + Context InvoiceContext `json:"context"` + Units []InvoiceUnit `json:"units"` + HeaderXML []byte `json:"headerXML,omitempty"` + SellerAuthenticationXML []byte `json:"sellerAuthenticationXML,omitempty"` + TaxSupervisionXML []byte `json:"taxSupervisionXML,omitempty"` + TaxBureauSignatureXML []byte `json:"taxBureauSignatureXML,omitempty"` +} + +// invoiceWriteField 保存按规范排列的字段与值 +type invoiceWriteField struct{ name, value string } + +// invoiceXMLSpan 保存节点原始字节边界 +type invoiceXMLSpan struct { + node *invoiceXMLNode + begin, inner, close, end int + empty bool +} + +// invoiceXMLPatch 保存不重序列化其他节点的字节修改 +type invoiceXMLPatch struct { + begin, end int + data []byte +} + +// InvoiceFormats 返回独立格式能力副本,历史航空格式仅允许读取和原位修改 +// 返回: 已适配格式列表 +func InvoiceFormats() []InvoiceFormat { + result := []InvoiceFormat{{Kind: "einv", Standard: "EInvoice", Version: "0.3", Read: true, Create: true, Edit: true, Items: true}} + for _, kind := range []string{"inv", "einv", "atr", "rai"} { + result = append(result, InvoiceFormat{Kind: kind, Standard: "http://xbrl.mof.gov.cn/taxonomy/2023-12-31/" + kind, Version: "2023-12-31", Read: true, Create: true, Edit: true, Items: kind == "inv" || kind == "atr"}) + } + return append(result, InvoiceFormat{Kind: "atr", Standard: "http://xbrl.mof.gov.cn/taxonomy/2021-11-30/atr", Version: "2021-11-30", Read: true, Edit: true, Items: true}) +} + +// DecodeInvoices 提取独立XML或XBRL中的发票,不执行联网或完整XSD验证 +// 入参: ctx 取消上下文,data 原始XML +// 返回: 提取结果及解析错误 +func DecodeInvoices(ctx context.Context, data []byte) (InvoiceReport, error) { + return parseInvoiceXML(ctx, data, InvoiceSource{}) +} + +// invoiceWriteContext 统一空上下文 +// 入参: ctx 取消上下文 +// 返回: 可用上下文 +func invoiceWriteContext(ctx context.Context) context.Context { + if ctx == nil { + return context.Background() + } + return ctx +} + +// invoiceWriteFormat 查找完整格式标识,不推测缺失版本 +// 入参: value 发票 +// 返回: 格式与匹配状态 +func invoiceWriteFormat(value Invoice) (InvoiceFormat, bool) { + for _, f := range InvoiceFormats() { + if value.Kind == f.Kind && value.Standard == f.Standard && value.Version == f.Version { + return f, true + } + } + return InvoiceFormat{}, false +} + +// ValidateInvoice 检查已映射字段的词法、金额及可表达性,不替代税务业务或XSD验证 +// 入参: ctx 取消上下文,value 发票 +// 返回: 字段诊断及取消错误 +func ValidateInvoice(ctx context.Context, value Invoice) ([]InvoiceIssue, error) { + ctx = invoiceWriteContext(ctx) + if err := ctx.Err(); err != nil { + return nil, err + } + if err := invoiceWriteLimits(ctx, value, InvoiceEncodingOptions{}); err != nil { + return nil, err + } + if len(value.Items) > 100000 || len(value.Facts) > 100000 { + return nil, fmt.Errorf("invoice exceeds structural limit") + } + var issues []InvoiceIssue + add := func(code, field, message string) { + issues = append(issues, InvoiceIssue{Code: code, Field: field, Message: message}) + } + f, ok := invoiceWriteFormat(value) + if !ok { + add("unsupported", "Standard", "unsupported invoice format or version") + return issues, nil + } + for _, field := range []invoiceWriteField{{"Number", value.Number}, {"Date", value.Date}, {"TotalAmount", value.TotalAmount}} { + if strings.TrimSpace(field.value) == "" { + add("required", field.name, "missing invoice field") + } + } + if value.Date != "" { + date := strings.Replace(value.Date, " ", "T", 1) + if _, ok := invoicePeriodTime(date); !ok { + add("invalid-date", "Date", "invalid invoice date") + } + } + if value.Currency != "" && !invoiceCurrencyUnit("{http://www.xbrl.org/2003/iso4217}"+value.Currency) { + add("invalid-value", "Currency", "invalid ISO currency code") + } + for _, field := range []invoiceWriteField{{"Amount", value.Amount}, {"TaxAmount", value.TaxAmount}, {"TotalAmount", value.TotalAmount}} { + if field.value != "" && invoiceDecimal(field.value) == nil { + add("invalid-decimal", field.name, "invalid decimal lexical value") + } + } + report := InvoiceReport{} + invoiceAmountCheck(&value, &report, InvoiceSource{}) + issues = append(issues, report.Issues...) + if !f.Items && len(value.Items) > 0 { + add("unsupported", "Items", "format has no supported invoice item tuple") + } + for i, item := range value.Items { + if err := ctx.Err(); err != nil { + return nil, err + } + for _, field := range []invoiceWriteField{{"Quantity", item.Quantity}, {"UnitPrice", item.UnitPrice}, {"Amount", item.Amount}, {"TaxAmount", item.TaxAmount}} { + if field.value != "" && invoiceDecimal(field.value) == nil { + add("invalid-decimal", fmt.Sprintf("Items[%d].%s", i, field.name), "invalid decimal lexical value") + } + } + if value.Standard == "EInvoice" && item.TaxRate != "" && invoiceDecimal(item.TaxRate) == nil { + add("invalid-decimal", fmt.Sprintf("Items[%d].TaxRate", i), "invalid decimal lexical value") + } + } + if value.Kind == "inv" || value.Standard == "EInvoice" { + for _, total := range []invoiceWriteField{{"Amount", value.Amount}, {"TaxAmount", value.TaxAmount}} { + sum := new(big.Rat) + complete := len(value.Items) > 0 + for _, item := range value.Items { + s := item.Amount + if total.name == "TaxAmount" { + s = item.TaxAmount + } + n := invoiceDecimal(s) + if n == nil { + complete = false + break + } + sum.Add(sum, n) + } + if n := invoiceDecimal(total.value); complete && n != nil && sum.Cmp(n) != 0 { + add("amount-mismatch", total.name, "item sum differs from invoice total") + } + } + } + total := 0 + for _, field := range invoiceWriteAllValues(value) { + if err := ctx.Err(); err != nil { + return nil, err + } + total += len(field.value) + if total > invoiceXMLLimit { + return nil, fmt.Errorf("invoice exceeds size limit") + } + if !invoiceWriteText(field.value) { + add("invalid-value", field.name, "invalid XML character or oversized field") + } + } + return issues, nil +} + +// invoiceWriteAllValues 收集公共字段及扩展事实文本 +// 入参: value 发票 +// 返回: 待校验字段 +func invoiceWriteAllValues(value Invoice) []invoiceWriteField { + r := []invoiceWriteField{{"Number", value.Number}, {"Code", value.Code}, {"Date", value.Date}, {"Status", value.Status}, {"Remarks", value.Remarks}} + for _, p := range []InvoiceParty{value.Buyer, value.Seller} { + for _, s := range []string{p.Name, p.TaxID, p.Address, p.Phone, p.Bank, p.Account} { + r = append(r, invoiceWriteField{"Party", s}) + } + } + for _, item := range value.Items { + for _, s := range []string{item.Name, item.Specification, item.Unit, item.Quantity, item.UnitPrice, item.Amount, item.TaxRate, item.TaxAmount} { + r = append(r, invoiceWriteField{"Items", s}) + } + for _, f := range item.Fields { + r = append(r, invoiceWriteField{f.Name, f.Value}) + } + } + for _, f := range value.Facts { + r = append(r, invoiceWriteField{f.Name, f.Value}) + } + return r +} + +// invoiceWriteText 检查XML文本且不替换非法字符 +// 入参: value 原始文本 +// 返回: 文本是否合法 +func invoiceWriteText(value string) bool { + if len(value) > invoiceXMLLimit || !utf8.ValidString(value) { + return false + } + for _, r := range value { + if r != 9 && r != 10 && r != 13 && (r < 32 || r == 0xfffe || r == 0xffff) { + return false + } + } + return true +} + +// invoiceWriteHeader 返回明确映射的规范字段顺序 +// 入参: value 发票 +// 返回: 字段名列表 +func invoiceWriteHeader(value Invoice) []string { + switch value.Kind { + case "inv": + return strings.Fields("CodeOfInvoice NumberOfInvoice DateOfIssue NameOfPurchaser TaxpayerIdentificationNumberUnifiedSocialCreditCodeOfPurchaser TotalAmountExcludingTax TotalTaxAmount TaxIncludedAmountInFigures NameOfSeller TaxpayerIdentificationNumberUnifiedSocialCreditCodeOfSeller Remarks") + case "einv": + return strings.Fields("InvoiceNumber SellerName SellerIdNum TotalAmWithoutTax RequestTime TotalTaxAm TotalTax-includedAmount") + case "atr": + return strings.Fields("ElectronicInvoiceAirTransportReceiptNumber IssuingStatus VatTaxAmount TotalAmount IssueDate NameOfPurchaser UnifiedSocialCreditCodeOfPurchaser NameOfSeller") + case "rai": + return strings.Fields("ElectronicInvoiceRailwayETicketNumber DateOfIssue NameOfPurchaser UnifiedSocialCreditCodeOfPurchaser TotalAmountExcludingTax TaxAmount Fare NameOfSeller UnifiedSocialCreditCodeOfSeller Remarks") + } + return nil +} + +// invoiceWriteItemNames 返回元组内部字段顺序 +// 入参: kind 票种 +// 返回: 规范字段列表 +func invoiceWriteItemNames(kind string) []string { + if kind == "atr" { + return strings.Fields("DepartureStation DestinationStation FlightSegment Carrier Flight Class CarrierDate DepartureTime FareBasis EffectiveDate ExpirationDate FreeBaggageAllowance") + } + return strings.Fields("NatureOfInvoiceLine NameOfTaxableGoodsOrServices Specification UnitOfGoodsOrServices QuantityOfGoodsOrServices UnitPriceOfGoodsOrServices AmountExcludingTax TaxRate TaxAmount") +} + +// invoiceWriteTuple 返回已支持的明细元组名称 +// 入参: kind 票种 +// 返回: 元组名 +func invoiceWriteTuple(kind string) string { + if kind == "atr" { + return "DetailInformationOfAirTicketTuple" + } + return "InformationOfTaxableGoodsOrServicesDetailItemsTuple" +} + +// invoiceWriteEscape 转义已校验的XML值 +// 入参: value 文本 +// 返回: XML安全文本 +func invoiceWriteEscape(value string) string { + var b bytes.Buffer + _ = xml.EscapeText(&b, []byte(value)) + return b.String() +} + +// EncodeInvoice 从明确业务字段生成独立XML,不将提取摘要覆盖原始载荷 +// 入参: ctx 取消上下文,value 发票 +// 返回: 结构化XML及创建错误 +func EncodeInvoice(ctx context.Context, value Invoice) ([]byte, error) { + return EncodeInvoiceWithOptions(ctx, value, InvoiceEncodingOptions{}) +} + +// EncodeInvoiceWithOptions 使用调用方指定的XBRL定义创建独立结构化文件 +// 入参: ctx 上下文,value 发票,options 分类标准入口、上下文及单位 +// 返回: XML及创建错误 +func EncodeInvoiceWithOptions(ctx context.Context, value Invoice, options InvoiceEncodingOptions) ([]byte, error) { + ctx = invoiceWriteContext(ctx) + if err := invoiceWriteLimits(ctx, value, options); err != nil { + return nil, err + } + issues, err := ValidateInvoice(ctx, value) + if err != nil { + return nil, err + } + if len(issues) > 0 { + return nil, fmt.Errorf("invoice %s: %s", issues[0].Field, issues[0].Message) + } + f, _ := invoiceWriteFormat(value) + if !f.Create { + return nil, fmt.Errorf("invoice format does not support standalone creation") + } + if err := invoiceWriteCreation(value); err != nil { + return nil, err + } + var data []byte + if value.Standard == "EInvoice" { + data, err = invoiceEncodePlain(value) + if err == nil { + data, err = invoiceWritePlainSections(ctx, data, options) + } + } else { + data, err = invoiceEncodeXBRL(value, options) + } + if err != nil { + return nil, err + } + report, err := DecodeInvoices(ctx, data) + if err != nil { + return nil, err + } + if len(report.Issues) > 0 { + return nil, fmt.Errorf("encoded invoice: %s", report.Issues[0].Message) + } + if len(report.Invoices) != 1 { + return nil, fmt.Errorf("encoded invoice has ambiguous contexts") + } + if err = invoiceWriteRepresented(value, report.Invoices[0]); err != nil { + return nil, err + } + return data, nil +} + +// invoiceWriteLimits 在构造字段列表和XML前限制节点数及转义后的保守空间上界 +// 入参: ctx 上下文,value 发票,options 显式定义和XML片段 +// 返回: 取消或限额错误 +func invoiceWriteLimits(ctx context.Context, value Invoice, options InvoiceEncodingOptions) error { + if err := ctx.Err(); err != nil { + return err + } + if len(value.Items) > 100000 || len(value.Facts) > 100000 || len(options.Units) > 256 || len(options.SchemaRef) > 4096 || len(options.Context.Scheme) > 4096 || len(options.Context.Identifier) > 64<<10 { + return fmt.Errorf("invoice exceeds structural limit") + } + total := 0 + charge := func(values ...string) error { + if err := ctx.Err(); err != nil { + return err + } + if total > invoiceXMLLimit-512 { + return fmt.Errorf("invoice exceeds encoded size limit") + } + total += 512 + for _, s := range values { + if len(s) > (invoiceXMLLimit-total)/6 { + return fmt.Errorf("invoice exceeds encoded size limit") + } + total += 6 * len(s) + } + return nil + } + for _, s := range []string{value.Kind, value.Standard, value.Version, value.Number, value.Code, value.Date, value.Currency, value.Status, value.Amount, value.TaxAmount, value.TotalAmount, value.Remarks} { + if err := charge(s); err != nil { + return err + } + } + for _, party := range []InvoiceParty{value.Buyer, value.Seller} { + if err := charge(party.Name, party.TaxID, party.Address, party.Phone, party.Bank, party.Account); err != nil { + return err + } + } + facts := func(fields []InvoiceFact) error { + if len(fields) > 100000 { + return fmt.Errorf("invoice exceeds structural limit") + } + for _, f := range fields { + if err := charge(f.Name, f.Namespace, f.Value, f.Context, f.Unit, f.Decimals, f.Precision); err != nil { + return err + } + if len(f.Dimensions) > 128 { + return fmt.Errorf("invoice exceeds structural limit") + } + for _, d := range f.Dimensions { + if err := charge(d.Name, d.Namespace, d.Value); err != nil { + return err + } + } + } + return nil + } + if err := facts(value.Facts); err != nil { + return err + } + for _, item := range value.Items { + for _, text := range []string{item.Name, item.Specification, item.Unit, item.Quantity, item.UnitPrice, item.Amount, item.TaxRate, item.TaxAmount} { + if err := charge(text); err != nil { + return err + } + } + if err := facts(item.Fields); err != nil { + return err + } + } + if err := charge(options.SchemaRef, options.Context.ID, options.Context.Identifier, options.Context.Scheme, options.Context.Instant, options.Context.StartDate, options.Context.EndDate); err != nil { + return err + } + for _, unit := range options.Units { + if len(unit.Namespace) > 4096 { + return fmt.Errorf("invoice unit namespace exceeds limit") + } + if err := charge(unit.ID, unit.Namespace, unit.Name); err != nil { + return err + } + } + for _, raw := range [][]byte{options.HeaderXML, options.SellerAuthenticationXML, options.TaxSupervisionXML, options.TaxBureauSignatureXML} { + if len(raw) > invoiceXMLLimit-total { + return fmt.Errorf("invoice sections exceed size limit") + } + total += len(raw) + } + return nil +} + +// invoiceWriteRepresented 拒绝格式无法承载的非空公共字段 +// 入参: want 输入业务数据,got 编码后数据 +// 返回: 不可表达字段错误 +func invoiceWriteRepresented(want, got Invoice) error { + a := []string{want.Number, want.Code, want.Date, want.Status, want.Amount, want.TaxAmount, want.TotalAmount, want.Remarks, want.Buyer.Name, want.Buyer.TaxID, want.Buyer.Address, want.Buyer.Phone, want.Buyer.Bank, want.Buyer.Account, want.Seller.Name, want.Seller.TaxID, want.Seller.Address, want.Seller.Phone, want.Seller.Bank, want.Seller.Account} + b := []string{got.Number, got.Code, got.Date, got.Status, got.Amount, got.TaxAmount, got.TotalAmount, got.Remarks, got.Buyer.Name, got.Buyer.TaxID, got.Buyer.Address, got.Buyer.Phone, got.Buyer.Bank, got.Buyer.Account, got.Seller.Name, got.Seller.TaxID, got.Seller.Address, got.Seller.Phone, got.Seller.Bank, got.Seller.Account} + for i, v := range a { + if v != "" && v != b[i] { + return fmt.Errorf("invoice format cannot represent field value at index %d", i) + } + } + if want.Currency != "" && want.Currency != got.Currency { + return fmt.Errorf("invoice format cannot represent currency") + } + if len(want.Items) != len(got.Items) { + return fmt.Errorf("invoice format cannot represent items") + } + for i, v := range want.Items { + w := got.Items[i] + for j, a := range []string{v.Name, v.Specification, v.Unit, v.Quantity, v.UnitPrice, v.Amount, v.TaxRate, v.TaxAmount} { + b := []string{w.Name, w.Specification, w.Unit, w.Quantity, w.UnitPrice, w.Amount, w.TaxRate, w.TaxAmount}[j] + if a != "" && a != b { + return fmt.Errorf("invoice format cannot represent item %d field %d", i, j) + } + } + } + return nil +} + +// invoiceEncodeXBRL 构造单主体实例,扩展事实不得携带无法重建的上下文或元组 +// 入参: value 发票,options 显式分类标准、上下文和单位 +// 返回: XBRL数据及表达错误 +func invoiceEncodeXBRL(value Invoice, options InvoiceEncodingOptions) ([]byte, error) { + if options.SchemaRef == "" || !invoiceWriteText(options.SchemaRef) || !invoiceNCName(options.Context.ID) || options.Context.Identifier == "" || options.Context.Scheme == "" { + return nil, fmt.Errorf("XBRL creation requires schema reference and explicit context") + } + for _, s := range []string{options.Context.Identifier, options.Context.Scheme, options.Context.Instant, options.Context.StartDate, options.Context.EndDate} { + if !invoiceWriteText(s) { + return nil, fmt.Errorf("invalid XBRL context text") + } + } + if value.Currency == "" { + return nil, fmt.Errorf("XBRL creation requires an explicit currency") + } + var b strings.Builder + b.WriteString(xml.Header) + fmt.Fprintf(&b, ``, invoiceXBRL, invoiceXSI, value.Standard) + fmt.Fprintf(&b, `%s`, invoiceWriteEscape(options.SchemaRef), options.Context.ID, invoiceWriteEscape(options.Context.Scheme), invoiceWriteEscape(options.Context.Identifier)) + if options.Context.Instant != "" { + if options.Context.StartDate != "" || options.Context.EndDate != "" { + return nil, fmt.Errorf("ambiguous XBRL period") + } + fmt.Fprintf(&b, "%s", invoiceWriteEscape(options.Context.Instant)) + } else { + fmt.Fprintf(&b, "%s%s", invoiceWriteEscape(options.Context.StartDate), invoiceWriteEscape(options.Context.EndDate)) + } + b.WriteString("") + ids := map[string]bool{options.Context.ID: true} + for _, unit := range options.Units { + if !invoiceNCName(unit.ID) || !invoiceNCName(unit.Name) || unit.Namespace == "" || !invoiceWriteText(unit.Namespace) || ids[unit.ID] { + return nil, fmt.Errorf("invalid or duplicate XBRL unit") + } + ids[unit.ID] = true + fmt.Fprintf(&b, `m:%s`, unit.ID, invoiceWriteEscape(unit.Namespace), unit.Name) + } + header := map[string]bool{} + for _, name := range invoiceWriteHeader(value) { + p := invoiceXMLField(&value, value.Kind, name) + if p != nil && *p != "" { + if err := invoiceWriteFact(&b, value, InvoiceFact{Name: name, Value: *p}, options); err != nil { + return nil, err + } + } + header[name] = true + } + for _, fact := range value.Facts { + if fact.Source.NodePath != "" || fact.Namespace == invoiceXBRL { + return nil, fmt.Errorf("source facts require source-preserving invoice update") + } + if header[fact.Name] { + return nil, fmt.Errorf("duplicate mapped invoice fact %s", fact.Name) + } + if err := invoiceWriteFact(&b, value, fact, options); err != nil { + return nil, err + } + header[fact.Name] = true + } + for _, item := range value.Items { + fmt.Fprintf(&b, "", invoiceWriteTuple(value.Kind)) + facts := map[string]InvoiceFact{} + for _, fact := range item.Fields { + if _, ok := facts[fact.Name]; ok { + return nil, fmt.Errorf("duplicate invoice item fact") + } + facts[fact.Name] = fact + } + for _, name := range invoiceWriteItemNames(value.Kind) { + fact, has := facts[name] + p := invoiceItemField(&item, value.Kind, name) + if p != nil && *p != "" { + if has && (fact.Nil || fact.Value != *p) { + return nil, fmt.Errorf("conflicting invoice item fact %s", name) + } + if !has { + fact = InvoiceFact{Name: name, Value: *p} + } + has = true + } + if has { + if err := invoiceWriteFact(&b, value, fact, options); err != nil { + return nil, err + } + delete(facts, name) + } + } + if len(facts) > 0 { + return nil, fmt.Errorf("unsupported invoice item fields") + } + fmt.Fprintf(&b, "", invoiceWriteTuple(value.Kind)) + } + b.WriteString("") + return []byte(b.String()), nil +} + +// invoiceWriteFact 写入单个无维度事实,数值不经过浮点转换 +// 入参: b 输出,value 发票,fact 事实,options 上下文和单位 +// 返回: 不支持的事实错误 +func invoiceWriteFact(b *strings.Builder, value Invoice, fact InvoiceFact, options InvoiceEncodingOptions) error { + if !invoiceWriteKnownFact(value.Kind, fact.Name) { + return fmt.Errorf("unsupported invoice concept %s", fact.Name) + } + if !invoiceNCName(fact.Name) || fact.Namespace != "" && fact.Namespace != value.Standard || len(fact.Dimensions) > 0 || fact.Context != "" && fact.Context != options.Context.ID { + return fmt.Errorf("unsupported invoice fact context or namespace") + } + if fact.Nil && fact.Value != "" { + return fmt.Errorf("nil invoice fact contains value") + } + if !fact.Nil && invoiceWriteDateFact(value.Kind, fact.Name) { + if _, ok := invoicePeriodTime(fact.Value); !ok || strings.ContainsAny(fact.Value, "T ") { + return fmt.Errorf("invalid invoice date fact %s", fact.Name) + } + } + fmt.Fprintf(b, `%s", invoiceWriteEscape(fact.Value), fact.Name) + return nil +} + +// invoicePlainGroups 返回官方公共字段分组 +// 返回: 组名与字段顺序 +func invoicePlainGroups() []invoiceWriteField { + return []invoiceWriteField{{"SellerInformation", "SellerIdNum SellerName SellerAddr SellerTelNum SellerBankName SellerBankAccNum"}, {"BuyerInformation", "BuyerIdNum BuyerName"}, {"BasicInformation", "TotalAmWithoutTax TotalTaxAm TotalTax-includedAmount"}, {"AdditionalInformation", "Remark"}} +} + +// invoiceEncodePlain 构造官方0.3字段路径,不捏造税务签名或编号 +// 入参: value 发票 +// 返回: XML及不可表达的扩展错误 +func invoiceEncodePlain(value Invoice) ([]byte, error) { + if len(value.Facts) > 0 { + return nil, fmt.Errorf("plain XML facts require source-preserving invoice update") + } + var b strings.Builder + b.WriteString(xml.Header + `
0.3
`) + for _, group := range invoicePlainGroups() { + if group.name == "AdditionalInformation" { + continue + } + fmt.Fprintf(&b, "<%s>", group.name) + for _, name := range strings.Fields(group.value) { + p, _ := invoicePlainField(&value, nil, "/EInvoice/EInvoiceData/"+group.name+"/"+name) + if p != nil && *p != "" { + fmt.Fprintf(&b, "<%s>%s", name, invoiceWriteEscape(*p), name) + } + } + fmt.Fprintf(&b, "", group.name) + } + for _, item := range value.Items { + if len(item.Fields) > 0 { + return nil, fmt.Errorf("plain XML item facts require source-preserving invoice update") + } + b.WriteString("") + for _, name := range strings.Fields("ItemName SpecMod MeaUnits Quantity UnPrice Amount TaxRate ComTaxAm") { + p, _ := invoicePlainField(&value, &item, "/EInvoice/EInvoiceData/IssuItemInformation/"+name) + if p != nil && *p != "" { + fmt.Fprintf(&b, "<%s>%s", name, invoiceWriteEscape(*p), name) + } + } + b.WriteString("") + } + if value.Remarks != "" { + fmt.Fprintf(&b, "%s", invoiceWriteEscape(value.Remarks)) + } + fmt.Fprintf(&b, "%s%s
", invoiceWriteEscape(value.Number), invoiceWriteEscape(value.Date)) + return []byte(b.String()), nil +} + +// invoiceWriteSpans 将有界解析树与原始字节边界关联,保留BOM和空元素 +// 入参: ctx 上下文,data XML +// 返回: 根节点、路径索引及错误 +func invoiceWriteSpans(ctx context.Context, data []byte) (*invoiceXMLNode, map[string]invoiceXMLSpan, error) { + root, err := invoiceXMLTree(ctx, data) + if err != nil { + return nil, nil, err + } + var nodes []*invoiceXMLNode + var walk func(*invoiceXMLNode) + walk = func(n *invoiceXMLNode) { + nodes = append(nodes, n) + for _, c := range n.children { + walk(c) + } + } + walk(root) + d := xml.NewDecoder(bytes.NewReader(data)) + spans := map[string]invoiceXMLSpan{} + var stack []invoiceXMLSpan + index := 0 + for { + if err := ctx.Err(); err != nil { + return nil, nil, err + } + before := int(d.InputOffset()) + token, err := d.Token() + if err == io.EOF { + break + } + if err != nil { + return nil, nil, err + } + after := int(d.InputOffset()) + switch token.(type) { + case xml.StartElement: + n := nodes[index] + index++ + empty := after >= 2 && bytes.Equal(data[after-2:after], []byte("/>")) + stack = append(stack, invoiceXMLSpan{node: n, begin: before, inner: after, empty: empty}) + case xml.EndElement: + s := stack[len(stack)-1] + stack = stack[:len(stack)-1] + s.close = before + s.end = after + if s.empty { + s.close = s.inner + s.end = s.inner + } + spans[s.node.path] = s + } + } + return root, spans, nil +} + +// invoiceApplyXMLPatches 原子合并互不重叠的字节修改 +// 入参: ctx 上下文,data 原文,patches 修改列表 +// 返回: 新XML及错误 +func invoiceApplyXMLPatches(ctx context.Context, data []byte, patches []invoiceXMLPatch) ([]byte, error) { + sort.SliceStable(patches, func(i, j int) bool { return patches[i].begin < patches[j].begin }) + var b bytes.Buffer + offset := 0 + for _, p := range patches { + if err := ctx.Err(); err != nil { + return nil, err + } + if p.begin < offset || p.end < p.begin || p.end > len(data) { + return nil, fmt.Errorf("overlapping invoice XML changes") + } + if b.Len()+p.begin-offset+len(p.data) > invoiceXMLLimit { + return nil, fmt.Errorf("invoice XML exceeds size limit") + } + b.Write(data[offset:p.begin]) + b.Write(p.data) + offset = p.end + } + if b.Len()+len(data)-offset > invoiceXMLLimit { + return nil, fmt.Errorf("invoice XML exceeds size limit") + } + b.Write(data[offset:]) + return b.Bytes(), nil +} + +// PatchInvoice 精确修改已有事实叶节点,未知节点及其他字节保持不变 +// 入参: ctx 上下文,data 原始XML,changes 节点修改与原值或摘要前置条件 +// 返回: 修改后XML及路径、校验或取消错误 +func PatchInvoice(ctx context.Context, data []byte, changes []InvoiceFieldChange) ([]byte, error) { + ctx = invoiceWriteContext(ctx) + report, err := DecodeInvoices(ctx, data) + if err != nil { + return nil, err + } + if len(report.Invoices) == 0 { + return nil, fmt.Errorf("unsupported invoice XML") + } + _, spans, err := invoiceWriteSpans(ctx, data) + if err != nil { + return nil, err + } + allowed := map[string]bool{} + for _, inv := range report.Invoices { + for _, f := range inv.Facts { + if f.Namespace != invoiceXBRL { + allowed[f.Source.NodePath] = true + } + } + } + sum := sha256.Sum256(data) + revision := hex.EncodeToString(sum[:]) + seen := map[string]bool{} + var patches []invoiceXMLPatch + for _, change := range changes { + path := change.Source.NodePath + s, ok := spans[path] + if !ok || !allowed[path] || len(s.node.children) > 0 || seen[path] { + return nil, fmt.Errorf("missing, ambiguous or non-leaf invoice path") + } + seen[path] = true + if change.Revision == "" && change.ExpectedValue == nil { + return nil, fmt.Errorf("invoice change requires source revision or expected value") + } + if change.Revision != "" && change.Revision != revision { + return nil, fmt.Errorf("stale invoice source revision") + } + oldNil := invoiceXMLAttr(s.node, invoiceXSI, "nil") + isNil := oldNil == "true" || oldNil == "1" + expectedNil := false + if change.ExpectedNil != nil { + expectedNil = *change.ExpectedNil + } + if change.ExpectedValue != nil && (*change.ExpectedValue != s.node.text.String() || expectedNil != isNil) { + return nil, fmt.Errorf("stale invoice field value") + } + if !invoiceWriteText(change.Value) || change.Nil && change.Value != "" || change.Delete && (change.Nil || change.Value != "") { + return nil, fmt.Errorf("invalid invoice field change") + } + if !change.Delete && change.Nil == isNil && change.Value == s.node.text.String() { + continue + } + if change.Delete { + patches = append(patches, invoiceXMLPatch{s.begin, s.end, nil}) + continue + } + replacement, err := invoiceWriteLeaf(s.node, change.Value, change.Nil) + if err != nil { + return nil, err + } + if change.Nil == isNil && !s.empty { + patches = append(patches, invoiceXMLPatch{s.inner, s.close, []byte(invoiceWriteEscape(change.Value))}) + } else { + patches = append(patches, invoiceXMLPatch{s.begin, s.end, replacement}) + } + } + result, err := invoiceApplyXMLPatches(ctx, data, patches) + if err != nil { + return nil, err + } + after, err := DecodeInvoices(ctx, result) + if err != nil { + return nil, err + } + if len(after.Invoices) == 0 { + return nil, fmt.Errorf("invoice patch removes all recognized facts") + } + if err := invoiceWriteNewIssues(report.Issues, after.Issues); err != nil { + return nil, err + } + if err := invoiceValidateXMLWrite(ctx, data, result); err != nil { + return nil, err + } + return result, nil +} + +// invoiceValidateXMLWrite 检查已有字段词法及相对原文新增的损坏,不强制补齐历史创建字段 +// 入参: ctx 上下文,before 原文,nil表示导入,after 待写入XML +// 返回: 新增或变更诊断错误 +func invoiceValidateXMLWrite(ctx context.Context, before, after []byte) error { + ctx = invoiceWriteContext(ctx) + var original map[string]InvoiceIssue + if before != nil { + var err error + original, err = invoiceWriteXMLProblems(ctx, before, true) + if err != nil { + return err + } + } + problems, err := invoiceWriteXMLProblems(ctx, after, before != nil) + if err != nil { + return err + } + keys := make([]string, 0, len(problems)) + for key := range problems { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + if _, ok := original[key]; !ok { + issue := problems[key] + return fmt.Errorf("invoice %s: %s", issue.Field, issue.Message) + } + } + return nil +} + +// invoiceWriteXMLProblems 按节点路径及问题值区分诊断,避免不同候选的同类问题相互抵消 +// 入参: ctx 上下文,data 原文,tuples 是否检查元组必有字段 +// 返回: 定位诊断及解析错误 +func invoiceWriteXMLProblems(ctx context.Context, data []byte, tuples bool) (map[string]InvoiceIssue, error) { + report, err := DecodeInvoices(ctx, data) + if err != nil { + return nil, err + } + root, err := invoiceXMLTree(ctx, data) + if err != nil { + return nil, err + } + problems := map[string]InvoiceIssue{} + nodes := map[string]*invoiceXMLNode{} + add := func(issue InvoiceIssue, value string) { + hash := sha256.Sum256([]byte(value)) + key := issue.Code + "\x00" + issue.Field + "\x00" + issue.Source.NodePath + "\x00" + issue.Message + "\x00" + hex.EncodeToString(hash[:]) + problems[key] = issue + } + var walk func(*invoiceXMLNode, string) error + walk = func(n *invoiceXMLNode, path string) error { + if err := ctx.Err(); err != nil { + return err + } + nodes[n.path] = n + if n.name.Space != "" { + path = "!" + path + } + path += "/" + n.name.Local + kind, _ := invoiceTaxonomy(n.name.Space) + nilValue := invoiceXMLAttr(n, invoiceXSI, "nil") + empty := nilValue == "true" || nilValue == "1" + date := kind != "" && invoiceWriteDateFact(kind, n.name.Local) + plainDate := root.name == (xml.Name{Local: "EInvoice"}) && path == "/EInvoice/TaxSupervisionInfo/IssueTime" + if len(n.children) == 0 && !empty && (date || plainDate) { + value := n.text.String() + parseValue := value + if plainDate { + parseValue = strings.Replace(value, " ", "T", 1) + } + _, valid := invoicePeriodTime(parseValue) + if !valid || date && strings.ContainsAny(value, "T ") { + add(InvoiceIssue{Code: "invalid-date", Field: n.name.Local, Message: "invalid invoice date", Source: InvoiceSource{NodePath: n.path}}, value) + } + } + if tuples && kind != "" && invoiceItemTuple(kind, n.name.Local) { + required := strings.Fields("NatureOfInvoiceLine NameOfTaxableGoodsOrServices AmountExcludingTax TaxRate TaxAmount") + if kind == "atr" { + required = strings.Fields("DepartureStation DestinationStation FlightSegment Carrier Flight Class FareBasis FreeBaggageAllowance") + } else if n.name.Local == "InformationOfTollFeeDetailItemsTuple" { + required = strings.Fields("NatureOfInvoiceLine NameOfTollFeeItems PlateNumber TypesOfVehicle EntranceDate ExitDate AmountExcludingTax TaxRate TaxAmount") + } + for _, name := range required { + if len(invoiceChildren(n, n.name.Space, name)) == 0 { + add(InvoiceIssue{Code: "required-tuple-field", Field: name, Message: "missing invoice tuple field", Source: InvoiceSource{NodePath: n.path}}, "") + } + } + } + for _, child := range n.children { + if err := walk(child, path); err != nil { + return err + } + } + return nil + } + if err := walk(root, ""); err != nil { + return nil, err + } + for _, issue := range report.Issues { + value := "" + if n := nodes[issue.Source.NodePath]; n != nil { + value = n.text.String() + "\x00" + invoiceXMLAttr(n, invoiceXSI, "nil") + } + add(issue, value) + } + return problems, nil +} + +// invoiceWriteLeaf 使用展开名称写入单个节点,并保留原有属性 +// 入参: n 原节点,value 新值,nilValue 空值标记 +// 返回: 节点XML及编码错误 +func invoiceWriteLeaf(n *invoiceXMLNode, value string, nilValue bool) ([]byte, error) { + var b bytes.Buffer + e := xml.NewEncoder(&b) + start := xml.StartElement{Name: n.name} + var prefixes []string + for prefix := range n.ns { + if prefix != "" && prefix != "xml" { + prefixes = append(prefixes, prefix) + } + } + sort.Strings(prefixes) + for _, prefix := range prefixes { + start.Attr = append(start.Attr, xml.Attr{Name: xml.Name{Local: "xmlns:" + prefix}, Value: n.ns[prefix]}) + } + for _, a := range n.attrs { + if a.Name.Space == "xmlns" || a.Name.Local == "xmlns" && a.Name.Space == "" || a.Name.Space == invoiceXSI && a.Name.Local == "nil" || nilValue && a.Name.Space == "" && (a.Name.Local == "decimals" || a.Name.Local == "precision") { + continue + } + start.Attr = append(start.Attr, a) + } + if nilValue { + start.Attr = append(start.Attr, xml.Attr{Name: xml.Name{Space: invoiceXSI, Local: "nil"}, Value: "true"}) + } else if invoiceXMLAttr(n, invoiceXSI, "nil") == "true" || invoiceXMLAttr(n, invoiceXSI, "nil") == "1" { + kind, _ := invoiceTaxonomy(n.name.Space) + if invoiceNumeric(kind, n.name.Local) { + start.Attr = append(start.Attr, xml.Attr{Name: xml.Name{Local: "decimals"}, Value: "INF"}) + } + } + if err := e.EncodeToken(start); err != nil { + return nil, err + } + if err := e.EncodeToken(xml.CharData(value)); err != nil { + return nil, err + } + if err := e.EncodeToken(start.End()); err != nil { + return nil, err + } + if err := e.Flush(); err != nil { + return nil, err + } + return b.Bytes(), nil +} + +// invoiceWriteNewIssues 拒绝修改新增的解析诊断,不隐藏原始文件诊断 +// 入参: before 修改前诊断,after 修改后诊断 +// 返回: 新增诊断错误 +func invoiceWriteNewIssues(before, after []InvoiceIssue) error { + counts := map[string]int{} + key := func(i InvoiceIssue) string { return i.Code + "\x00" + i.Field + "\x00" + i.Message } + for _, i := range before { + counts[key(i)]++ + } + for _, i := range after { + k := key(i) + if counts[k] == 0 { + return fmt.Errorf("invoice update: %s", i.Message) + } + counts[k]-- + } + return nil +} + +// UpdateInvoiceXML 更新指定候选的公共字段和明细,保留未映射原文及其他候选 +// 入参: ctx 上下文,data 原XML,invoiceIndex 候选序号,value 完整目标值 +// 返回: 修改后XML及不可安全表达的修改错误 +func UpdateInvoiceXML(ctx context.Context, data []byte, invoiceIndex int, value Invoice) ([]byte, error) { + ctx = invoiceWriteContext(ctx) + before, err := DecodeInvoices(ctx, data) + if err != nil { + return nil, err + } + if invoiceIndex < 0 || invoiceIndex >= len(before.Invoices) { + return nil, fmt.Errorf("invoice index out of range") + } + old := before.Invoices[invoiceIndex] + if reflect.DeepEqual(old, value) { + return bytes.Clone(data), nil + } + if value.Kind != old.Kind || value.Standard != old.Standard || value.Version != old.Version { + return nil, fmt.Errorf("invoice update cannot change format") + } + if value.Currency != old.Currency { + return nil, fmt.Errorf("invoice update cannot change shared currency units") + } + oldIssues, err := ValidateInvoice(ctx, old) + if err != nil { + return nil, err + } + newIssues, err := ValidateInvoice(ctx, value) + if err != nil { + return nil, err + } + filter := func(issues []InvoiceIssue) []InvoiceIssue { + var r []InvoiceIssue + for _, i := range issues { + if i.Code != "required" { + r = append(r, i) + } + } + return r + } + if err = invoiceWriteNewIssues(filter(oldIssues), filter(newIssues)); err != nil { + return nil, err + } + root, spans, err := invoiceWriteSpans(ctx, data) + if err != nil { + return nil, err + } + if value.Standard == "EInvoice" { + var expanded []invoiceXMLPatch + for _, s := range spans { + n := s.node + if !s.empty || n.name.Space != "" || n.parent == nil { + continue + } + container := n.parent == root && (n.name.Local == "EInvoiceData" || n.name.Local == "TaxSupervisionInfo") + if n.parent.name == (xml.Name{Local: "EInvoiceData"}) && n.parent.parent == root { + for _, g := range invoicePlainGroups() { + container = container || g.name == n.name.Local + } + } + if container { + p, e := invoiceWriteInsert(s, nil, data) + if e != nil { + return nil, e + } + expanded = append(expanded, p) + } + } + if len(expanded) > 0 { + normalized, e := invoiceApplyXMLPatches(ctx, data, expanded) + if e != nil { + return nil, e + } + return UpdateInvoiceXML(ctx, normalized, invoiceIndex, value) + } + } + if err = invoiceWriteUnmapped(old.Facts, value.Facts); err != nil { + return nil, err + } + clean := value + clean.Facts = nil + clean.Sources = nil + clean.Items = append([]InvoiceItem(nil), value.Items...) + if clean.Standard != "EInvoice" { + for _, name := range invoiceWriteHeader(clean) { + previous := invoiceXMLField(&old, old.Kind, name) + target := invoiceXMLField(&clean, clean.Kind, name) + if previous != nil && target != nil && *previous == *target { + *target = "" + } + } + } + for i := range clean.Items { + clean.Items[i].Fields = nil + if value.Standard != "EInvoice" && (value.Kind == "atr" || value.Kind == "inv") { + for _, f := range value.Items[i].Fields { + if !invoiceWriteContains(invoiceWriteItemNames(value.Kind), f.Name) { + continue + } + if p := invoiceItemField(&clean.Items[i], value.Kind, f.Name); p != nil { + if *p == "" && !f.Nil { + continue + } + f.Value = *p + if *p != "" { + f.Nil = false + } + } + f.Source = InvoiceSource{} + f.Context = "" + clean.Items[i].Fields = append(clean.Items[i].Fields, f) + } + } + } + if reflect.DeepEqual(old.Items, value.Items) { + clean.Items = nil + } + if invoiceWriteCreation(old) == nil { + if err = invoiceWriteCreation(value); err != nil { + return nil, err + } + } + var generated []byte + if value.Standard == "EInvoice" { + generated, err = invoiceEncodePlain(clean) + } else { + options, e := invoiceWriteOriginalOptions(root, old) + if e != nil { + return nil, e + } + generated, err = invoiceEncodeXBRL(clean, options) + } + if err != nil { + return nil, err + } + generatedRoot, err := invoiceXMLTree(ctx, generated) + if err != nil { + return nil, err + } + var patches []invoiceXMLPatch + var trailingGroups []invoiceXMLPatch + var itemParent, generatedParent *invoiceXMLNode + if value.Standard == "EInvoice" { + if len(invoiceChildren(root, "", "EInvoiceData")) != 1 || len(invoiceChildren(root, "", "TaxSupervisionInfo")) != 1 { + return nil, fmt.Errorf("invoice requires unique data and supervision groups") + } + itemParent = invoiceChildren(root, "", "EInvoiceData")[0] + generatedParent = invoiceChildren(generatedRoot, "", "EInvoiceData")[0] + for _, group := range append(invoicePlainGroups(), invoiceWriteField{"TaxSupervisionInfo", "InvoiceNumber IssueTime"}) { + parent, newParent := itemParent, generatedParent + if group.name == "TaxSupervisionInfo" { + parent, newParent = root, generatedRoot + } + existing := invoiceChildren(parent, "", group.name) + desired := invoiceChildren(newParent, "", group.name) + if len(existing) > 1 { + return nil, fmt.Errorf("ambiguous invoice group %s", group.name) + } + if len(existing) == 0 { + if len(desired) > 0 && len(desired[0].children) > 0 { + raw, e := invoiceWriteNode(desired[0]) + if e != nil { + return nil, e + } + p, e := invoiceWriteOrderedInsert(data, spans, parent, "", group.name, strings.Fields("SellerInformation BuyerInformation BasicInformation IssuItemInformation SpecificInformation AdditionalInformation"), raw) + if e != nil { + return nil, e + } + if group.name == "AdditionalInformation" { + trailingGroups = append(trailingGroups, p) + } else { + patches = append(patches, p) + } + } + continue + } + var target *invoiceXMLNode + if len(desired) > 0 { + target = desired[0] + } + retained := invoiceXMLNode{} + if target != nil { + retained = *target + retained.children = append([]*invoiceXMLNode(nil), target.children...) + } + for _, name := range strings.Fields(group.value) { + path := "/EInvoice/EInvoiceData/" + group.name + "/" + name + if group.name == "TaxSupervisionInfo" { + path = "/EInvoice/TaxSupervisionInfo/" + name + } + previous, _ := invoicePlainField(&old, nil, path) + desired, _ := invoicePlainField(&value, nil, path) + if previous != nil && desired != nil && *previous == *desired && len(invoiceChildren(&retained, "", name)) == 0 { + retained.children = append(retained.children, invoiceChildren(existing[0], "", name)...) + } + } + ps, e := invoiceWriteMergeChildren(data, spans, existing[0], &retained, "", strings.Fields(group.value)) + if e != nil { + return nil, e + } + patches = append(patches, ps...) + } + } else { + itemParent, generatedParent = root, generatedRoot + contextID := "" + for _, f := range old.Facts { + if f.Namespace == invoiceXBRL && f.Name == "context" { + contextID = f.Context + break + } + } + if contextID == "" { + return nil, fmt.Errorf("missing invoice context") + } + filtered := *root + filtered.children = nil + for _, n := range root.children { + if invoiceXMLAttr(n, "", "contextRef") == contextID { + filtered.children = append(filtered.children, n) + } + } + for _, name := range invoiceWriteHeader(value) { + previous := invoiceXMLField(&old, old.Kind, name) + desired := invoiceXMLField(&value, value.Kind, name) + if previous != nil && desired != nil && *previous == *desired { + generatedRoot.children = append(generatedRoot.children, invoiceChildren(&filtered, value.Standard, name)...) + } + } + ps, e := invoiceWriteMergeChildren(data, spans, &filtered, generatedRoot, value.Standard, invoiceWriteHeader(value)) + if e != nil { + return nil, e + } + patches = append(patches, ps...) + } + ps, err := invoiceWriteMergeItems(ctx, data, spans, itemParent, generatedParent, old, value) + if err != nil { + return nil, err + } + patches = append(patches, ps...) + patches = append(patches, trailingGroups...) + result, err := invoiceApplyXMLPatches(ctx, data, patches) + if err != nil { + return nil, err + } + after, err := DecodeInvoices(ctx, result) + if err != nil { + return nil, err + } + if err = invoiceWriteNewIssues(before.Issues, after.Issues); err != nil { + return nil, err + } + if len(after.Invoices) != len(before.Invoices) { + return nil, fmt.Errorf("invoice update changed candidate contexts") + } + var matches []Invoice + for _, inv := range after.Invoices { + if inv.Kind == value.Kind && inv.Standard == value.Standard { + if value.Standard == "EInvoice" { + matches = append(matches, inv) + continue + } + for _, f := range inv.Facts { + if f.Name == "context" && f.Namespace == invoiceXBRL { + for _, of := range old.Facts { + if of.Name == "context" && of.Namespace == invoiceXBRL && of.Context == f.Context { + matches = append(matches, inv) + } + } + } + } + } + } + if len(matches) != 1 { + return nil, fmt.Errorf("invoice update target is ambiguous") + } + if err = invoiceWriteRepresented(value, matches[0]); err != nil { + return nil, err + } + if err = invoiceValidateXMLWrite(ctx, data, result); err != nil { + return nil, err + } + return result, nil +} + +// invoiceWriteContains 判断字段是否位于已知顺序表 +// 入参: names 字段列表,name 字段 +// 返回: 是否存在 +func invoiceWriteContains(names []string, name string) bool { + for _, n := range names { + if n == name { + return true + } + } + return false +} + +// invoiceWriteUnmapped 拒绝通过摘要更改或删除未映射事实,原文修改使用PatchInvoice +// 入参: before 原事实,after 目标事实 +// 返回: 不支持的事实修改错误 +func invoiceWriteUnmapped(before, after []InvoiceFact) error { + if len(after) == 0 { + return nil + } + old := map[string]InvoiceFact{} + for _, f := range before { + old[f.Source.NodePath] = f + } + for _, f := range after { + prior, ok := old[f.Source.NodePath] + if !ok { + return fmt.Errorf("new unmapped facts require explicit source editing") + } + if f.Name != prior.Name || f.Namespace != prior.Namespace || f.Context != prior.Context || f.Unit != prior.Unit || f.Decimals != prior.Decimals || f.Precision != prior.Precision || !slices.Equal(f.Dimensions, prior.Dimensions) { + return fmt.Errorf("invoice update cannot alter fact metadata") + } + if f.Value != prior.Value || f.Nil != prior.Nil { + return fmt.Errorf("unmapped fact changes require PatchInvoice") + } + } + return nil +} + +// invoiceWriteOriginalOptions 读取原上下文及单位,不猜测或改写定义 +// 入参: root 原XML,value 原候选 +// 返回: 原定义及不支持的复杂上下文错误 +func invoiceWriteOriginalOptions(root *invoiceXMLNode, value Invoice) (InvoiceEncodingOptions, error) { + var options InvoiceEncodingOptions + ref := "" + for _, f := range value.Facts { + if f.Namespace == invoiceXBRL && f.Name == "context" { + ref = f.Context + break + } + } + for _, n := range root.children { + if n.name == (xml.Name{Space: "http://www.xbrl.org/2003/linkbase", Local: "schemaRef"}) && options.SchemaRef == "" { + options.SchemaRef = invoiceXMLAttr(n, "http://www.w3.org/1999/xlink", "href") + } + if n.name == (xml.Name{Space: invoiceXBRL, Local: "context"}) && invoiceXMLAttr(n, "", "id") == ref { + dims, ok := invoiceContext(n) + if !ok || len(dims) > 0 { + return options, fmt.Errorf("invoice update requires an unambiguous nondimensional context") + } + entity := invoiceChildren(n, invoiceXBRL, "entity")[0] + id := invoiceChildren(entity, invoiceXBRL, "identifier")[0] + options.Context = InvoiceContext{ID: ref, Identifier: id.text.String(), Scheme: invoiceXMLAttr(id, "", "scheme")} + period := invoiceChildren(n, invoiceXBRL, "period")[0] + for _, p := range period.children { + switch p.name.Local { + case "instant": + options.Context.Instant = p.text.String() + case "startDate": + options.Context.StartDate = p.text.String() + case "endDate": + options.Context.EndDate = p.text.String() + } + } + } + if n.name == (xml.Name{Space: invoiceXBRL, Local: "unit"}) { + ms := invoiceChildren(n, invoiceXBRL, "measure") + if len(ms) == 1 { + q, ok := invoiceQName(ms[0], ms[0].text.String()) + if ok { + options.Units = append(options.Units, InvoiceUnit{ID: invoiceXMLAttr(n, "", "id"), Namespace: q.Space, Name: q.Local}) + } + } + } + } + if options.SchemaRef == "" { + return options, fmt.Errorf("invoice source has no schema reference") + } + return options, nil +} + +// invoiceWriteNode 序列化生成的节点,使移动节点的命名空间自足 +// 入参: n 节点 +// 返回: XML及编码错误 +func invoiceWriteNode(n *invoiceXMLNode) ([]byte, error) { + var b bytes.Buffer + e := xml.NewEncoder(&b) + var walk func(*invoiceXMLNode) error + walk = func(c *invoiceXMLNode) error { + start := xml.StartElement{Name: c.name} + for _, a := range c.attrs { + if a.Name.Space != "xmlns" && a.Name != (xml.Name{Local: "xmlns"}) { + start.Attr = append(start.Attr, a) + } + } + if err := e.EncodeToken(start); err != nil { + return err + } + for _, p := range c.parts { + if p.child != nil { + if err := walk(p.child); err != nil { + return err + } + } else { + if err := e.EncodeToken(xml.CharData(p.text)); err != nil { + return err + } + } + } + return e.EncodeToken(start.End()) + } + if err := walk(n); err != nil { + return nil, err + } + if err := e.Flush(); err != nil { + return nil, err + } + return b.Bytes(), nil +} + +// invoiceWriteInsert 生成父节点内追加内容的修改,支持空元素 +// 入参: span 父节点边界,raw 新内容,data 原文 +// 返回: 插入修改及错误 +func invoiceWriteInsert(span invoiceXMLSpan, raw, data []byte) (invoiceXMLPatch, error) { + if !span.empty { + return invoiceXMLPatch{span.close, span.close, raw}, nil + } + open := data[span.begin:span.inner] + nameEnd := bytes.IndexAny(open, " \t\r\n/>") + if nameEnd < 2 { + return invoiceXMLPatch{}, fmt.Errorf("invalid empty invoice element") + } + name := string(open[1:nameEnd]) + replacement := append([]byte(nil), open[:len(open)-2]...) + replacement = append(replacement, '>') + replacement = append(replacement, raw...) + replacement = append(replacement, []byte("")...) + return invoiceXMLPatch{span.begin, span.end, replacement}, nil +} + +// invoiceWriteMergeChildren 合并已知叶节点,其他属性、子节点及空白保持原样 +// 入参: data 原文,spans 边界,old 原父节点,desired 目标父节点,space 命名空间,names 已知字段 +// 返回: 修改列表及歧义错误 +func invoiceWriteMergeChildren(data []byte, spans map[string]invoiceXMLSpan, old, desired *invoiceXMLNode, space string, names []string) ([]invoiceXMLPatch, error) { + var patches []invoiceXMLPatch + for rank, name := range names { + existing := invoiceChildren(old, space, name) + var targets []*invoiceXMLNode + if desired != nil { + targets = invoiceChildren(desired, space, name) + } + if len(existing) > 1 || len(targets) > 1 { + return nil, fmt.Errorf("ambiguous invoice field %s", name) + } + var raw []byte + if len(targets) > 0 { + var err error + raw, err = invoiceWriteNode(targets[0]) + if err != nil { + return nil, err + } + } + if len(existing) > 0 { + s := spans[existing[0].path] + if len(existing[0].children) > 0 { + return nil, fmt.Errorf("non-leaf invoice field %s", name) + } + if len(targets) == 0 && (existing[0].text.String() == "" || invoiceXMLAttr(existing[0], invoiceXSI, "nil") == "true" || invoiceXMLAttr(existing[0], invoiceXSI, "nil") == "1") { + continue + } + if len(targets) > 0 && existing[0].text.String() == targets[0].text.String() && invoiceXMLAttr(existing[0], invoiceXSI, "nil") == invoiceXMLAttr(targets[0], invoiceXSI, "nil") { + continue + } + if len(targets) > 0 { + oldNil := invoiceXMLAttr(existing[0], invoiceXSI, "nil") + newNil := invoiceXMLAttr(targets[0], invoiceXSI, "nil") + wasNil := oldNil == "true" || oldNil == "1" + isNil := newNil == "true" || newNil == "1" + if !s.empty && wasNil == isNil { + patches = append(patches, invoiceXMLPatch{s.inner, s.close, []byte(invoiceWriteEscape(targets[0].text.String()))}) + continue + } + var err error + raw, err = invoiceWriteLeaf(existing[0], targets[0].text.String(), isNil) + if err != nil { + return nil, err + } + } + patches = append(patches, invoiceXMLPatch{s.begin, s.end, raw}) + } else if len(raw) > 0 { + position := -1 + for _, next := range names[rank+1:] { + nodes := invoiceChildren(old, space, next) + if len(nodes) > 0 { + candidate := spans[nodes[0].path].begin + if position < 0 || candidate < position { + position = candidate + } + } + } + if position >= 0 { + patches = append(patches, invoiceXMLPatch{position, position, raw}) + } else { + p, err := invoiceWriteInsert(spans[old.path], raw, data) + if err != nil { + return nil, err + } + patches = append(patches, p) + } + } + } + return patches, nil +} + +// invoiceWriteMergeItems 按原明细来源重排或修改元组,删除仅作用于显式移除的明细 +// 入参: ctx 上下文,data 原文,spans 边界,parent 原父节点,generated 新父节点,old 原发票,value 目标发票 +// 返回: 明细修改及错误 +func invoiceWriteMergeItems(ctx context.Context, data []byte, spans map[string]invoiceXMLSpan, parent, generated *invoiceXMLNode, old, value Invoice) ([]invoiceXMLPatch, error) { + if reflect.DeepEqual(old.Items, value.Items) { + return nil, nil + } + space, name := value.Standard, invoiceWriteTuple(value.Kind) + names := invoiceWriteItemNames(value.Kind) + if value.Standard == "EInvoice" { + space = "" + name = "IssuItemInformation" + names = strings.Fields("ItemName SpecMod MeaUnits Quantity UnPrice Amount TaxRate ComTaxAm") + } + targets := invoiceChildren(generated, space, name) + if len(targets) != len(value.Items) { + return nil, fmt.Errorf("unsupported invoice item shape") + } + original := map[string]*invoiceXMLNode{} + for _, item := range old.Items { + for _, f := range item.Fields { + s := spans[f.Source.NodePath] + if s.node != nil && s.node.parent != nil && s.node.parent.parent == parent && s.node.parent.name.Space == space { + ref := invoiceXMLAttr(s.node, "", "contextRef") + for _, child := range s.node.parent.children { + if other := invoiceXMLAttr(child, "", "contextRef"); other != "" && other != ref { + return nil, fmt.Errorf("invoice item shares multiple contexts") + } + } + original[s.node.parent.path] = s.node.parent + break + } + } + } + if len(original) != len(old.Items) { + return nil, fmt.Errorf("invoice items require explicit tuple sources") + } + seen := map[string]bool{} + var combined []byte + for i, item := range value.Items { + if err := ctx.Err(); err != nil { + return nil, err + } + path := "" + for _, f := range item.Fields { + s := spans[f.Source.NodePath] + if s.node != nil && s.node.parent != nil && original[s.node.parent.path] != nil { + if path != "" && path != s.node.parent.path { + return nil, fmt.Errorf("mixed invoice item sources") + } + path = s.node.parent.path + } + } + if path == "" { + for _, f := range item.Fields { + mapped := invoiceItemField(&InvoiceItem{}, value.Kind, f.Name) + if value.Standard == "EInvoice" || !invoiceWriteContains(names, f.Name) { + return nil, fmt.Errorf("unsupported new invoice item fact %s", f.Name) + } + if mapped != nil { + p := invoiceItemField(&item, value.Kind, f.Name) + if f.Nil && *p != "" || !f.Nil && f.Value != *p { + return nil, fmt.Errorf("conflicting new invoice item fact %s", f.Name) + } + } + } + raw, err := invoiceWriteNode(targets[i]) + if err != nil { + return nil, err + } + combined = append(combined, raw...) + continue + } + if seen[path] { + return nil, fmt.Errorf("duplicate invoice item source") + } + seen[path] = true + for _, f := range item.Fields { + if invoiceWriteContains(names, f.Name) { + continue + } + s, ok := spans[f.Source.NodePath] + if !ok || s.node.parent == nil || s.node.parent.path != path || f.Value != s.node.text.String() || f.Nil != (invoiceXMLAttr(s.node, invoiceXSI, "nil") == "true" || invoiceXMLAttr(s.node, invoiceXSI, "nil") == "1") { + return nil, fmt.Errorf("unmapped item fact changes require PatchInvoice") + } + } + n := original[path] + ps, err := invoiceWriteMergeChildren(data, spans, n, targets[i], space, names) + if err != nil { + return nil, err + } + s := spans[path] + for j := range ps { + ps[j].begin -= s.begin + ps[j].end -= s.begin + } + raw, err := invoiceApplyXMLPatches(ctx, data[s.begin:s.end], ps) + if err != nil { + return nil, err + } + combined = append(combined, raw...) + } + var ordered []invoiceXMLSpan + for path := range original { + ordered = append(ordered, spans[path]) + } + sort.Slice(ordered, func(i, j int) bool { return ordered[i].begin < ordered[j].begin }) + var patches []invoiceXMLPatch + for i, s := range ordered { + raw := []byte(nil) + if i == 0 { + raw = combined + } + patches = append(patches, invoiceXMLPatch{s.begin, s.end, raw}) + } + if len(ordered) == 0 && len(combined) > 0 { + var p invoiceXMLPatch + var err error + if value.Standard == "EInvoice" { + p, err = invoiceWriteOrderedInsert(data, spans, parent, "", name, strings.Fields("SellerInformation BuyerInformation BasicInformation IssuItemInformation SpecificInformation AdditionalInformation"), combined) + } else { + p, err = invoiceWriteInsert(spans[parent.path], combined, data) + } + if err != nil { + return nil, err + } + patches = append(patches, p) + } + return patches, nil +} + +// invoiceWriteOrderedInsert 在后继已知元素前插入,未知节点不移动 +// 入参: data 原文,spans 边界,parent 父节点,space 命名空间,name 新节点名,names 规范顺序,raw 新内容 +// 返回: 插入修改及错误 +func invoiceWriteOrderedInsert(data []byte, spans map[string]invoiceXMLSpan, parent *invoiceXMLNode, space, name string, names []string, raw []byte) (invoiceXMLPatch, error) { + position := -1 + following := false + for _, known := range names { + if known == name { + following = true + continue + } + if !following { + continue + } + for _, node := range invoiceChildren(parent, space, known) { + offset := spans[node.path].begin + if position < 0 || offset < position { + position = offset + } + } + } + if position >= 0 { + return invoiceXMLPatch{position, position, raw}, nil + } + return invoiceWriteInsert(spans[parent.path], raw, data) +} + +// invoiceWriteCreation 检查官方元组序列中的必有字段,不虚构缺失业务值 +// 入参: value 发票 +// 返回: 缺少元组字段错误 +func invoiceWriteCreation(value Invoice) error { + if value.Standard == "EInvoice" { + return nil + } + for i, item := range value.Items { + required := strings.Fields("NatureOfInvoiceLine NameOfTaxableGoodsOrServices AmountExcludingTax TaxRate TaxAmount") + if value.Kind == "atr" { + required = strings.Fields("DepartureStation DestinationStation FlightSegment Carrier Flight Class FareBasis FreeBaggageAllowance") + } + for _, name := range required { + p := invoiceItemField(&item, value.Kind, name) + present := p != nil && *p != "" + for _, f := range item.Fields { + if f.Name == name && (f.Nil || f.Value != "") { + present = true + } + } + if !present { + return fmt.Errorf("invoice item %d requires %s", i, name) + } + } + } + return nil +} + +// invoiceWriteKnownFact 限制新建事实为已核实的分类标准概念 +// 入参: kind 票种,name 概念名 +// 返回: 是否支持新建 +func invoiceWriteKnownFact(kind, name string) bool { + if invoiceWriteContains(invoiceWriteHeader(Invoice{Kind: kind}), name) { + return true + } + if (kind == "inv" || kind == "atr") && invoiceWriteContains(invoiceWriteItemNames(kind), name) { + return true + } + var extras string + switch kind { + case "inv": + extras = "TypeOfInvoice UniqueCodeOfInvoice LocationOfInvoice IdentifyingCode NumberOfInvoiceMachine SecurityCode TaxIncludedAmountInWords Payee Reviewer Issuer AddressPhoneNumberOfPurchaser DepositBankAndAccountNumberOfPurchaser AddressPhoneNumberOfSeller DepositBankAndAccountNumberOfSeller" + case "atr": + extras = "PassengerName ValidIdNumber Endorsement NumberOfGpOrder Fare FuelSurcharge VatRate CivilAviationDevelopmentFund OtherTaxes ETicketNumber VerificationCode PromptInformation Insurance AgentCode IssueParty MarkingOfDomesticOrInternational" + case "rai": + extras = "TypeOfVoucher ETicketNumber IssueParty IssuePartyCode TypeOfBusiness DepartureStation PhonicsOfDepartureStation DestinationStation PhonicsOfDestinationStation TrainNumber TravelDate DepartureTime AirConditioningCharacteristics SeatLevel Carriage Seat TypeOfRailwayTicket IdNumber Name TaxRate AddressPhoneNumberOfPurchaser DepositBankAndAccountNumberOfPurchaser AddressPhoneNumberOfSeller DepositBankAndAccountNumberOfSeller" + } + return invoiceWriteContains(strings.Fields(extras), name) +} + +// invoiceWriteDateFact 识别规范中的日期类型,而非自由文本日期时间 +// 入参: kind 票种,name 概念名 +// 返回: 是否为日期事实 +func invoiceWriteDateFact(kind, name string) bool { + return kind == "inv" && name == "DateOfIssue" || kind == "atr" && invoiceWriteContains(strings.Fields("IssueDate CarrierDate EffectiveDate ExpirationDate"), name) || kind == "rai" && (name == "DateOfIssue" || name == "TravelDate") +} + +// invoiceWritePlainSections 接入调用方提供的官方头部、认证及签名片段,不生成税务签名 +// 入参: ctx 上下文,data 生成XML,options 显式业务片段 +// 返回: XML及片段错误 +func invoiceWritePlainSections(ctx context.Context, data []byte, options InvoiceEncodingOptions) ([]byte, error) { + root, spans, err := invoiceWriteSpans(ctx, data) + if err != nil { + return nil, err + } + var patches []invoiceXMLPatch + for _, section := range []struct { + name string + data []byte + }{{"Header", options.HeaderXML}, {"SellerAuthentication", options.SellerAuthenticationXML}, {"TaxSupervisionInfo", options.TaxSupervisionXML}, {"TaxBureauSignature", options.TaxBureauSignatureXML}} { + if len(section.data) == 0 { + continue + } + n, err := invoiceXMLTree(ctx, section.data) + if err != nil { + return nil, err + } + if n.name != (xml.Name{Local: section.name}) { + return nil, fmt.Errorf("invalid invoice section %s", section.name) + } + if section.name == "Header" { + vs := invoiceChildren(n, "", "Version") + if len(vs) != 1 || vs[0].text.String() != "0.3" { + return nil, fmt.Errorf("invalid invoice header version") + } + } + old := invoiceChildren(root, "", section.name) + if len(old) > 0 { + s := spans[old[0].path] + patches = append(patches, invoiceXMLPatch{s.begin, s.end, section.data}) + } else { + pos := spans[root.path].close + if section.name == "SellerAuthentication" { + pos = spans[invoiceChildren(root, "", "TaxSupervisionInfo")[0].path].begin + } + patches = append(patches, invoiceXMLPatch{pos, pos, section.data}) + } + } + return invoiceApplyXMLPatches(ctx, data, patches) +} diff --git a/ofdgo_invoice_xml.go b/ofdgo_invoice_xml.go index f9da81b..e135308 100644 --- a/ofdgo_invoice_xml.go +++ b/ofdgo_invoice_xml.go @@ -28,16 +28,16 @@ import ( "time" ) -// invoiceXBRL 表示XBRL实例命名空间。 +// invoiceXBRL 表示XBRL实例命名空间 const invoiceXBRL = "http://www.xbrl.org/2003/instance" -// invoiceXSI 表示XML Schema实例命名空间。 +// invoiceXSI 表示XML Schema实例命名空间 const invoiceXSI = "http://www.w3.org/2001/XMLSchema-instance" -// invoiceDecimalPattern 仅接受XML十进制词法。 +// invoiceDecimalPattern 仅接受XML十进制词法 var invoiceDecimalPattern = regexp.MustCompile(`^[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)$`) -// invoiceXMLNode 保存有界XML节点及原始文本切片。 +// invoiceXMLNode 保存有界XML节点及原始文本切片 type invoiceXMLNode struct { name xml.Name attrs []xml.Attr @@ -52,23 +52,23 @@ type invoiceXMLNode struct { parts []invoiceXMLPart } -// invoiceXMLPart 保留混合内容顺序,供类型化维度规范化使用。 +// invoiceXMLPart 保留混合内容顺序,供类型化维度规范化使用 type invoiceXMLPart struct { text string child *invoiceXMLNode } -// invoiceDecimalEqual 精确比较十进制词法值,不接受指数或分数。 -// 入参: a、b 原始十进制文本。 -// 返回: bool 两个合法值是否相等。 +// invoiceDecimalEqual 精确比较十进制词法值,不接受指数或分数 +// 入参: a、b 原始十进制文本 +// 返回: bool 两个合法值是否相等 func invoiceDecimalEqual(a, b string) bool { x, y := invoiceDecimal(a), invoiceDecimal(b) return x != nil && y != nil && x.Cmp(y) == 0 } -// invoiceDecimal 解析有界十进制文本。 -// 入参: s 原始文本。 -// 返回: *big.Rat 精确数值,非法时为空。 +// invoiceDecimal 解析有界十进制文本 +// 入参: s 原始文本 +// 返回: *big.Rat 精确数值,非法时为空 func invoiceDecimal(s string) *big.Rat { s = strings.TrimSpace(s) if len(s) > 256 || !invoiceDecimalPattern.MatchString(s) { @@ -81,9 +81,9 @@ func invoiceDecimal(s string) *big.Rat { return r } -// invoiceXMLAttr 按命名空间读取属性。 -// 入参: n 节点,space 命名空间,name 属性名。 -// 返回: string 属性值。 +// invoiceXMLAttr 按命名空间读取属性 +// 入参: n 节点,space 命名空间,name 属性名 +// 返回: string 属性值 func invoiceXMLAttr(n *invoiceXMLNode, space, name string) string { for _, a := range n.attrs { if a.Name.Space == space && a.Name.Local == name { @@ -93,9 +93,9 @@ func invoiceXMLAttr(n *invoiceXMLNode, space, name string) string { return "" } -// invoiceXMLHasAttr 区分缺省属性与空字符串属性。 -// 入参: n 节点,space 命名空间,name 属性名。 -// 返回: 属性是否存在。 +// invoiceXMLHasAttr 区分缺省属性与空字符串属性 +// 入参: n 节点,space 命名空间,name 属性名 +// 返回: 属性是否存在 func invoiceXMLHasAttr(n *invoiceXMLNode, space, name string) bool { for _, a := range n.attrs { if a.Name == (xml.Name{Space: space, Local: name}) { @@ -105,9 +105,9 @@ func invoiceXMLHasAttr(n *invoiceXMLNode, space, name string) bool { return false } -// invoiceXMLTree 读取受限XML树,不解析外部实体。 -// 入参: ctx 取消上下文,data 文档字节。 -// 返回: 根节点及解析错误。 +// invoiceXMLTree 读取受限XML树,不解析外部实体 +// 入参: ctx 取消上下文,data 文档字节 +// 返回: 根节点及解析错误 func invoiceXMLTree(ctx context.Context, data []byte) (*invoiceXMLNode, error) { if len(data) > 16<<20 { return nil, fmt.Errorf("invoice XML exceeds size limit") @@ -215,9 +215,9 @@ func invoiceXMLTree(ctx context.Context, data []byte) (*invoiceXMLNode, error) { return root, nil } -// invoiceTaxonomy 识别已核实的官方命名空间。 -// 入参: ns 命名空间URI。 -// 返回: 票种及版本,未知时为空。 +// invoiceTaxonomy 识别已核实的官方命名空间 +// 入参: ns 命名空间URI +// 返回: 票种及版本,未知时为空 func invoiceTaxonomy(ns string) (string, string) { switch ns { case "http://xbrl.mof.gov.cn/taxonomy/2023-12-31/inv": @@ -234,9 +234,9 @@ func invoiceTaxonomy(ns string) (string, string) { return "", "" } -// parseInvoiceXML 解析明确识别的发票格式,未知格式仅产生诊断。 -// 入参: ctx 取消上下文,data XML数据,source 来源。 -// 返回: InvoiceReport 独立候选与诊断,error XML或限额错误。 +// parseInvoiceXML 解析明确识别的发票格式,未知格式仅产生诊断 +// 入参: ctx 取消上下文,data XML数据,source 来源 +// 返回: InvoiceReport 独立候选与诊断,error XML或限额错误 func parseInvoiceXML(ctx context.Context, data []byte, source InvoiceSource) (InvoiceReport, error) { if ctx == nil { ctx = context.Background() @@ -498,9 +498,9 @@ func parseInvoiceXML(ctx context.Context, data []byte, source InvoiceSource) (In return report, nil } -// invoiceXMLField 映射官方分类标准明确规定的概念。 -// 入参: inv 发票,kind 标准类别,name 概念名。 -// 返回: 对应字段地址,未映射时为空。 +// invoiceXMLField 映射官方分类标准明确规定的概念 +// 入参: inv 发票,kind 标准类别,name 概念名 +// 返回: 对应字段地址,未映射时为空 func invoiceXMLField(inv *Invoice, kind, name string) *string { if kind == "inv" { switch name { @@ -588,9 +588,9 @@ func invoiceXMLField(inv *Invoice, kind, name string) *string { return nil } -// invoiceSortedNodes 为定义生成稳定顺序。 -// 入参: nodes 定义索引。 -// 返回: 排序后的标识。 +// invoiceSortedNodes 为定义生成稳定顺序 +// 入参: nodes 定义索引 +// 返回: 排序后的标识 func invoiceSortedNodes(nodes map[string]*invoiceXMLNode) []string { ids := make([]string, 0, len(nodes)) for id := range nodes { @@ -600,9 +600,9 @@ func invoiceSortedNodes(nodes map[string]*invoiceXMLNode) []string { return ids } -// invoiceQName 依据节点处的前缀绑定展开QName。 -// 入参: n 节点,value QName文本。 -// 返回: 展开名称及有效标志。 +// invoiceQName 依据节点处的前缀绑定展开QName +// 入参: n 节点,value QName文本 +// 返回: 展开名称及有效标志 func invoiceQName(n *invoiceXMLNode, value string) (xml.Name, bool) { value = strings.TrimSpace(value) parts := strings.Split(value, ":") @@ -626,9 +626,9 @@ func invoiceQName(n *invoiceXMLNode, value string) (xml.Name, bool) { return xml.Name{Space: ns, Local: local}, true } -// invoiceNCName 验证XML 1.0不含冒号的名称。 -// 入参: name 名称文本。 -// 返回: 是否满足NCName词法。 +// invoiceNCName 验证XML 1.0不含冒号的名称 +// 入参: name 名称文本 +// 返回: 是否满足NCName词法 func invoiceNCName(name string) bool { if name == "" { return false @@ -642,9 +642,9 @@ func invoiceNCName(name string) bool { return true } -// invoicePeriodTime 解析标准期间的日期或日期时间。 -// 入参: value 日期文本。 -// 返回: 时刻及有效标志。 +// invoicePeriodTime 解析标准期间的日期或日期时间 +// 入参: value 日期文本 +// 返回: 时刻及有效标志 func invoicePeriodTime(value string) (time.Time, bool) { value = strings.TrimSpace(value) if len(value) > 64 { @@ -668,14 +668,14 @@ func invoicePeriodTime(value string) (time.Time, bool) { return time.Time{}, false } -// invoiceExpanded 返回不依赖前缀的名称表示。 -// 入参: name 展开名称。 -// 返回: Clark名称。 +// invoiceExpanded 返回不依赖前缀的名称表示 +// 入参: name 展开名称 +// 返回: Clark名称 func invoiceExpanded(name xml.Name) string { return "{" + name.Space + "}" + name.Local } -// invoiceCanonicalXML 以展开名称序列化类型化维度,保留混合文本顺序。 -// 入参: n 类型化维度域元素。 -// 返回: 不依赖输入元素前缀的XML。 +// invoiceCanonicalXML 以展开名称序列化类型化维度,保留混合文本顺序 +// 入参: n 类型化维度域元素 +// 返回: 不依赖输入元素前缀的XML func invoiceCanonicalXML(n *invoiceXMLNode) string { var b bytes.Buffer e := xml.NewEncoder(&b) @@ -709,9 +709,9 @@ func invoiceCanonicalXML(n *invoiceXMLNode) string { return b.String() } -// invoiceChildren 按展开名称选择直接子节点。 -// 入参: n 父节点,space 命名空间,local 本地名。 -// 返回: 匹配节点。 +// invoiceChildren 按展开名称选择直接子节点 +// 入参: n 父节点,space 命名空间,local 本地名 +// 返回: 匹配节点 func invoiceChildren(n *invoiceXMLNode, space, local string) []*invoiceXMLNode { var result []*invoiceXMLNode for _, c := range n.children { @@ -722,9 +722,9 @@ func invoiceChildren(n *invoiceXMLNode, space, local string) []*invoiceXMLNode { return result } -// invoiceContext 校验主体、期间及维度,不把不同上下文合并。 -// 入参: n 上下文节点。 -// 返回: 维度及有效标志。 +// invoiceContext 校验主体、期间及维度,不把不同上下文合并 +// 入参: n 上下文节点 +// 返回: 维度及有效标志 func invoiceContext(n *invoiceXMLNode) ([]InvoiceDimension, bool) { entities, periods := invoiceChildren(n, invoiceXBRL, "entity"), invoiceChildren(n, invoiceXBRL, "period") if len(entities) != 1 || len(periods) != 1 { @@ -788,9 +788,9 @@ func invoiceContext(n *invoiceXMLNode) ([]InvoiceDimension, bool) { return dims, valid } -// invoiceUnit 校验单位结构并展开所有度量QName。 -// 入参: n 单位定义。 -// 返回: 规范单位表达及有效标志。 +// invoiceUnit 校验单位结构并展开所有度量QName +// 入参: n 单位定义 +// 返回: 规范单位表达及有效标志 func invoiceUnit(n *invoiceXMLNode) (string, bool) { measures := func(c *invoiceXMLNode) (string, bool) { if len(c.children) == 0 { @@ -823,9 +823,9 @@ func invoiceUnit(n *invoiceXMLNode) (string, bool) { return measures(n) } -// invoiceNumeric 判断已核实标准中的十进制概念。 -// 入参: kind 标准类别,name 概念名称。 -// 返回: 是否为数值概念。 +// invoiceNumeric 判断已核实标准中的十进制概念 +// 入参: kind 标准类别,name 概念名称 +// 返回: 是否为数值概念 func invoiceNumeric(kind, name string) bool { if name == "RecordedAmount" { return true @@ -860,9 +860,9 @@ func invoiceNumeric(kind, name string) bool { return false } -// invoiceAccuracy 校验XBRL精度属性,precision允许零。 -// 入参: value 原文,nonnegative 是否要求非负整数。 -// 返回: 是否为整数或INF。 +// invoiceAccuracy 校验XBRL精度属性,precision允许零 +// 入参: value 原文,nonnegative 是否要求非负整数 +// 返回: 是否为整数或INF func invoiceAccuracy(value string, nonnegative bool) bool { value = strings.TrimSpace(value) if value == "INF" { @@ -888,16 +888,16 @@ func invoiceAccuracy(value string, nonnegative bool) bool { return !nonnegative || !negative || !nonzero } -// invoiceMonetary 区分货币事实与数量、百分率。 -// 入参: kind 类别,name 概念名称。 -// 返回: 是否为已映射货币概念。 +// invoiceMonetary 区分货币事实与数量、百分率 +// 入参: kind 类别,name 概念名称 +// 返回: 是否为已映射货币概念 func invoiceMonetary(kind, name string) bool { return invoiceNumeric(kind, name) && name != "QuantityOfGoodsOrServices" && name != "VatRate" && name != "TaxRate" } -// invoiceCurrencyUnit 检查单一ISO币种度量,不猜测币种。 -// 入参: unit 展开的单位表示。 -// 返回: 是否为三位大写币种度量。 +// invoiceCurrencyUnit 检查单一ISO币种度量,不猜测币种 +// 入参: unit 展开的单位表示 +// 返回: 是否为三位大写币种度量 func invoiceCurrencyUnit(unit string) bool { const prefix = "{http://www.xbrl.org/2003/iso4217}" if !strings.HasPrefix(unit, prefix) { @@ -907,16 +907,16 @@ func invoiceCurrencyUnit(unit string) bool { return len(code) == 3 && code[0] >= 'A' && code[0] <= 'Z' && code[1] >= 'A' && code[1] <= 'Z' && code[2] >= 'A' && code[2] <= 'Z' } -// invoiceItemTuple 识别明细元组,不把会计分录当作商品。 -// 入参: kind 标准类别,name 元组名。 -// 返回: 是否为明细。 +// invoiceItemTuple 识别明细元组,不把会计分录当作商品 +// 入参: kind 标准类别,name 元组名 +// 返回: 是否为明细 func invoiceItemTuple(kind, name string) bool { return kind == "atr" && name == "DetailInformationOfAirTicketTuple" || kind == "inv" && (name == "InformationOfTaxableGoodsOrServicesDetailItemsTuple" || name == "InformationOfTollFeeDetailItemsTuple") } -// invoiceItemField 映射增值税商品明细公共字段。 -// 入参: item 明细,kind 类别,name 概念名。 -// 返回: 字段地址,其他明细仅保留事实。 +// invoiceItemField 映射增值税商品明细公共字段 +// 入参: item 明细,kind 类别,name 概念名 +// 返回: 字段地址,其他明细仅保留事实 func invoiceItemField(item *InvoiceItem, kind, name string) *string { if kind != "inv" { return nil @@ -942,9 +942,9 @@ func invoiceItemField(item *InvoiceItem, kind, name string) *string { return nil } -// invoiceAmountCheck 精确检查明确的价税合计关系,不推算航空费用。 -// 入参: inv 发票,report 诊断目标,source 来源。 -// 返回: 无。 +// invoiceAmountCheck 精确检查明确的价税合计关系,不推算航空费用 +// 入参: inv 发票,report 诊断目标,source 来源 +// 返回: 无 func invoiceAmountCheck(inv *Invoice, report *InvoiceReport, source InvoiceSource) { if inv.Kind != "inv" && inv.Kind != "einv" { return @@ -955,9 +955,9 @@ func invoiceAmountCheck(inv *Invoice, report *InvoiceReport, source InvoiceSourc } } -// invoicePlainXML 解析官方数电发票指南中的无命名空间0.3版本。 -// 入参: ctx 上下文,root 根节点,source 来源。 -// 返回: 发票及诊断,取消错误。 +// invoicePlainXML 解析官方数电发票指南中的无命名空间0.3版本 +// 入参: ctx 上下文,root 根节点,source 来源 +// 返回: 发票及诊断,取消错误 func invoicePlainXML(ctx context.Context, root *invoiceXMLNode, source InvoiceSource) (InvoiceReport, error) { report := InvoiceReport{} headers := invoiceChildren(root, "", "Header") @@ -1044,9 +1044,9 @@ func invoicePlainXML(ctx context.Context, root *invoiceXMLNode, source InvoiceSo return report, nil } -// invoicePlainField 按官方样例中的完整路径映射字段。 -// 入参: inv 发票,item 当前明细,path 完整无命名空间路径。 -// 返回: 字段地址及数值标志。 +// invoicePlainField 按官方样例中的完整路径映射字段 +// 入参: inv 发票,item 当前明细,path 完整无命名空间路径 +// 返回: 字段地址及数值标志 func invoicePlainField(inv *Invoice, item *InvoiceItem, path string) (*string, bool) { switch path { case "/EInvoice/TaxSupervisionInfo/InvoiceNumber": diff --git a/ofdgo_res.go b/ofdgo_res.go index 1097687..2692da1 100644 --- a/ofdgo_res.go +++ b/ofdgo_res.go @@ -140,7 +140,7 @@ type CompositeGraphicUnit struct { extentSet bool } -// UnmarshalXML 解析资源文件,隔离外部命名空间的同名集合。 +// UnmarshalXML 解析资源文件,隔离外部命名空间的同名集合 // 入参: d XML解码器, start 起始节点 // 返回: error 解码错误 func (res *Res) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { @@ -168,14 +168,14 @@ func (res *Res) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { }) } -// UnmarshalXML 解析颜色空间集合。 +// UnmarshalXML 解析颜色空间集合 // 入参: d XML解码器, start 起始节点 // 返回: error 解码错误 func (spaces *ColorSpaces) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { return decodeOFDCollection(d, start, "ColorSpace", &spaces.ColorSpace) } -// UnmarshalXML 解析颜色空间属性及调色板。 +// UnmarshalXML 解析颜色空间属性及调色板 // 入参: d XML解码器, start 起始节点 // 返回: error 解码错误 func (space *ColorSpace) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { @@ -195,14 +195,14 @@ func (space *ColorSpace) UnmarshalXML(d *xml.Decoder, start xml.StartElement) er }) } -// UnmarshalXML 解析字体集合。 +// UnmarshalXML 解析字体集合 // 入参: d XML解码器, start 起始节点 // 返回: error 解码错误 func (fonts *Fonts) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { return decodeOFDCollection(d, start, "Font", &fonts.Font) } -// UnmarshalXML 解析字体声明及内嵌字体路径。 +// UnmarshalXML 解析字体声明及内嵌字体路径 // 入参: d XML解码器, start 起始节点 // 返回: error 解码错误 func (font *Font) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { @@ -238,14 +238,14 @@ func (font *Font) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { }) } -// UnmarshalXML 解析多媒体集合。 +// UnmarshalXML 解析多媒体集合 // 入参: d XML解码器, start 起始节点 // 返回: error 解码错误 func (media *MultiMedias) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { return decodeOFDCollection(d, start, "MultiMedia", &media.MultiMedia) } -// UnmarshalXML 解析多媒体声明及包内文件路径。 +// UnmarshalXML 解析多媒体声明及包内文件路径 // 入参: d XML解码器, start 起始节点 // 返回: error 解码错误 func (media *MultiMedia) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { @@ -258,14 +258,14 @@ func (media *MultiMedia) UnmarshalXML(d *xml.Decoder, start xml.StartElement) er }) } -// UnmarshalXML 解析绘制参数集合。 +// UnmarshalXML 解析绘制参数集合 // 入参: d XML解码器, start 起始节点 // 返回: error 解码错误 func (params *DrawParams) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { return decodeOFDCollection(d, start, "DrawParam", ¶ms.DrawParam) } -// UnmarshalXML 解析复合图元资源集合。 +// UnmarshalXML 解析复合图元资源集合 // 入参: d XML解码器, start 起始节点 // 返回: error 解码错误 func (units *CompositeGraphicUnits) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { diff --git a/ofdgo_rich_layout.go b/ofdgo_rich_layout.go index 933d410..56f8665 100644 --- a/ofdgo_rich_layout.go +++ b/ofdgo_rich_layout.go @@ -29,14 +29,14 @@ import ( "golang.org/x/text/unicode/bidi" ) -// richLayout 保存局部标准对象、含墨迹的边界及各片段的字体锚点序号。 +// richLayout 保存局部标准对象、含墨迹的边界及各片段的字体锚点序号 type richLayout struct { objects []GraphicObject bounds Box anchors []int } -// richMetrics 保存片段字体、共用基线度量和实际需要的合成样式。 +// richMetrics 保存片段字体、共用基线度量和实际需要的合成样式 type richMetrics struct { font FontMetrics ascent, descent, height float64 @@ -44,7 +44,7 @@ type richMetrics struct { outlines map[uint16]GeometryPath } -// richSpan 保存方向和格式均一致的逻辑区间及行内视觉坐标。 +// richSpan 保存方向和格式均一致的逻辑区间及行内视觉坐标 type richSpan struct { start, end, owner int rtl bool @@ -52,7 +52,7 @@ type richSpan struct { x, width float64 } -// richContextShaper 将带上下文的区间塑形接入已有簇校验流程。 +// richContextShaper 将带上下文的区间塑形接入已有簇校验流程 type richContextShaper struct { FontShaperRun text []rune @@ -60,12 +60,12 @@ type richContextShaper struct { options TextShapeOptions } -// LayoutRichText 生成局部坐标的标准文字和路径,不修改页面或编辑历史。 -// 边界包含内容的排版高度和溢出墨迹,不包含空余段落宽度,可能有负的X或Y。 -// 非标准RGB文档按需复用或注册RGB颜色空间,返回对象可直接用于当前文档。 -// 注册不产生修订或撤销记录;失败时不保留新增资源和资源编号。 -// 提交对象后应创建新的Reader;已有Reader是独立快照,不包含新注册的资源。 -// 返回值的资源编号属于当前资源视图,切换文档、版本或替换资源快照后须重新排版。 +// LayoutRichText 生成局部坐标的标准文字和路径,不修改页面或编辑历史 +// 边界包含内容的排版高度和溢出墨迹,不包含空余段落宽度,可能有负的X或Y +// 非标准RGB文档按需复用或注册RGB颜色空间,返回对象可直接用于当前文档 +// 注册不产生修订或撤销记录;失败时不保留新增资源和资源编号 +// 提交对象后应创建新的Reader;已有Reader是独立快照,不包含新注册的资源 +// 返回值的资源编号属于当前资源视图,切换文档、版本或替换资源快照后须重新排版 func (e *Editor) LayoutRichText(value RichText, width float64) ([]GraphicObject, Box, error) { result, err := e.layoutRichText(value, width) if err != nil { @@ -85,7 +85,7 @@ func (e *Editor) LayoutRichText(value RichText, width float64) ([]GraphicObject, return result.objects, result.bounds, nil } -// layoutRichText 统一计算段落换行、视觉顺序和基线,颜色资源由调用方绑定。 +// layoutRichText 统一计算段落换行、视觉顺序和基线,颜色资源由调用方绑定 func (e *Editor) layoutRichText(value RichText, width float64) (richLayout, error) { if err := value.Validate(); err != nil { return richLayout{}, err @@ -327,7 +327,7 @@ func (e *Editor) layoutRichText(value RichText, width float64) (richLayout, erro return result, nil } -// richParagraphOptions 以整段原文检测双向排版,与普通文字的自动模式一致。 +// richParagraphOptions 以整段原文检测双向排版,与普通文字的自动模式一致 func richParagraphOptions(chars []rune, options TextLayout) TextLayout { if options.AutoBidi && !options.Shaping.Bidi { for _, char := range chars { @@ -341,7 +341,7 @@ func richParagraphOptions(chars []rune, options TextLayout) TextLayout { return options } -// richShapeLine 按方向、脚本和格式塑形整行,跨同格式片段合并,保留区间外的塑形上下文。 +// richShapeLine 按方向、脚本和格式塑形整行,跨同格式片段合并,保留区间外的塑形上下文 func richShapeLine(chars []rune, owners []int, value RichText, metrics []richMetrics, options TextLayout, face *typefont.Face) ([]richSpan, []float64, error) { advances := make([]float64, len(chars)) if len(chars) == 0 { @@ -430,7 +430,7 @@ func richShapeLine(chars []rune, owners []int, value RichText, metrics []richMet return spans, advances, nil } -// ShapeText 返回区间相对簇索引,同时保留完整行提供的上下文。 +// ShapeText 返回区间相对簇索引,同时保留完整行提供的上下文 func (s richContextShaper) ShapeText(_ string, size float64) ([]ShapedGlyph, error) { glyphs, err := s.ShapeTextRun(s.text, s.start, s.end, size, s.options) if err != nil { @@ -442,7 +442,7 @@ func (s richContextShaper) ShapeText(_ string, size float64) ([]ShapedGlyph, err return glyphs, nil } -// richPositionSpans 按核心排版相同的方向分组规则排列片段,按视觉簇分配字距及两端对齐。 +// richPositionSpans 按核心排版相同的方向分组规则排列片段,按视觉簇分配字距及两端对齐 func richPositionSpans(spans []richSpan, steps []float64, options TextLayout, x float64) error { order := make([]int, len(spans)) for i := range order { @@ -490,7 +490,7 @@ func richPositionSpans(spans []richSpan, steps []float64, options TextLayout, x return nil } -// richSpanText 将逻辑原文和绝对视觉位置写为标准字符字形映射。 +// richSpanText 将逻辑原文和绝对视觉位置写为标准字符字形映射 func richSpanText(chars []rune, span richSpan, format TextFormat, baseline float64) TextObject { text := richTextObject(format) code := TextCode{Value: escapeOFDText(string(chars[span.start:span.end]))} @@ -524,7 +524,7 @@ func richSpanText(chars []rune, span richSpan, format TextFormat, baseline float return text } -// richSpanOutline 使用现有字体及几何后端生成与文字合成样式一致的墨迹。 +// richSpanOutline 使用现有字体及几何后端生成与文字合成样式一致的墨迹 func richSpanOutline(span richSpan, format TextFormat, metrics richMetrics, geometry GeometryBackend, baseline float64) (GeometryPath, error) { font, ok := metrics.font.(FontOutlines) if !ok { @@ -567,7 +567,7 @@ func richSpanOutline(span richSpan, format TextFormat, metrics richMetrics, geom return result, nil } -// richSynthetic 与标准绘制一样避免对已加粗或倾斜的字体重复合成。 +// richSynthetic 与标准绘制一样避免对已加粗或倾斜的字体重复合成 func (e *Editor) richSynthetic(format TextFormat) (bool, bool) { bold, italic := format.Bold, format.Italic var definition *Font @@ -590,7 +590,7 @@ func (e *Editor) richSynthetic(format TextFormat) (bool, bool) { return bold, italic } -// richTextObject 构造具有显式填充和标准字重的文字对象。 +// richTextObject 构造具有显式填充和标准字重的文字对象 func richTextObject(format TextFormat) TextObject { fill, stroke := true, false weight := 400 @@ -600,7 +600,7 @@ func richTextObject(format TextFormat) TextObject { return TextObject{Font: format.Font, Size: format.Size, Weight: weight, Italic: format.Italic, Fill: &fill, Stroke: &stroke, FillColor: &FillColor{Value: richColor(format.Color)}} } -// richColor 将空前景色转换为标准黑色。 +// richColor 将空前景色转换为标准黑色 func richColor(value string) string { if value == "" { return "0 0 0" @@ -608,13 +608,13 @@ func richColor(value string) string { return value } -// richUnion 合并排版范围和墨迹范围,保留负坐标及零宽空行。 +// richUnion 合并排版范围和墨迹范围,保留负坐标及零宽空行 func richUnion(a, b Box) Box { x, y := math.Min(a.X, b.X), math.Min(a.Y, b.Y) return Box{X: x, Y: y, W: math.Max(a.X+a.W, b.X+b.W) - x, H: math.Max(a.Y+a.H, b.Y+b.H) - y} } -// richRectangle 使用标准填充路径绘制背景或文字装饰。 +// richRectangle 使用标准填充路径绘制背景或文字装饰 func richRectangle(box Box, color string) GraphicObject { fill, stroke := true, false return GraphicObject{Type: "PathObject", PathObject: PathObject{Boundary: editorBoxString(box), Fill: &fill, Stroke: &stroke, FillColor: &FillColor{Value: color}, AbbreviatedData: fmt.Sprintf("M 0 0 L %s 0 L %s %s L 0 %s C", ofdNumber(box.W), ofdNumber(box.W), ofdNumber(box.H), ofdNumber(box.H))}} diff --git a/ofdgo_rich_text.go b/ofdgo_rich_text.go index 8c9620f..fbbf3af 100644 --- a/ofdgo_rich_text.go +++ b/ofdgo_rich_text.go @@ -23,22 +23,22 @@ import ( "github.com/go-text/typesetting/segmenter" ) -// ErrNotRichText 表示对象没有富文本信息且不属于可提升的普通文字。 +// ErrNotRichText 表示对象没有富文本信息且不属于可提升的普通文字 var ErrNotRichText = errors.New("object is not rich text") -// RichText 保存有序文字片段及段落选项,长度和范围使用Unicode码点而非字节。 +// RichText 保存有序文字片段及段落选项,长度和范围使用Unicode码点而非字节 type RichText struct { Runs []RichTextRun `json:"runs"` Layout TextLayout `json:"layout"` } -// RichTextRun 保存文字及完整格式,换行使用LF,长度单位为毫米。 +// RichTextRun 保存文字及完整格式,换行使用LF,长度单位为毫米 type RichTextRun struct { Text string `json:"text"` Format TextFormat `json:"format"` } -// TextFormat 保存完整文字格式,Font为当前文档字体资源标识,颜色使用OFD RGB分量。 +// TextFormat 保存完整文字格式,Font为当前文档字体资源标识,颜色使用OFD RGB分量 type TextFormat struct { Font string `json:"font"` Size float64 `json:"size"` @@ -51,7 +51,7 @@ type TextFormat struct { Background string `json:"background"` } -// TextFormatPatch 保存格式增量,nil保持原值,false和空背景显式清除对应格式。 +// TextFormatPatch 保存格式增量,nil保持原值,false和空背景显式清除对应格式 type TextFormatPatch struct { Font *string `json:"font,omitempty"` Size *float64 `json:"size,omitempty"` @@ -64,7 +64,7 @@ type TextFormatPatch struct { Background *string `json:"background,omitempty"` } -// Text 返回包含显式换行的原文,不包含排版产生的软换行。 +// Text 返回包含显式换行的原文,不包含排版产生的软换行 // 返回: string 完整原文 func (v RichText) Text() string { var text strings.Builder @@ -74,11 +74,11 @@ func (v RichText) Text() string { return text.String() } -// Len 返回原文的Unicode码点数量。 +// Len 返回原文的Unicode码点数量 // 返回: int 码点数量 func (v RichText) Len() int { return utf8.RuneCountInString(v.Text()) } -// Validate 校验文字、格式、段落选项及跨片段的字素边界,不加载字体。 +// Validate 校验文字、格式、段落选项及跨片段的字素边界,不加载字体 // 返回: error 无效文字、格式或边界 func (v RichText) Validate() error { if len(v.Runs) == 0 { @@ -104,7 +104,7 @@ func (v RichText) Validate() error { return nil } -// validate 校验完整文字格式,不解析文档资源。 +// validate 校验完整文字格式,不解析文档资源 // 返回: error 格式错误 func (f TextFormat) validate() error { if strings.TrimSpace(f.Font) == "" || !finite(f.Size) || f.Size <= 0 { @@ -126,7 +126,7 @@ func (f TextFormat) validate() error { return nil } -// validateRichLayout 校验富文本段落的有限数值及枚举。 +// validateRichLayout 校验富文本段落的有限数值及枚举 // 入参: v 段落选项 // 返回: error 无效选项 func validateRichLayout(v TextLayout) error { @@ -141,7 +141,7 @@ func validateRichLayout(v TextLayout) error { return nil } -// richTextBoundaries 返回包含首尾位置的扩展字素边界。 +// richTextBoundaries 返回包含首尾位置的扩展字素边界 // 入参: value 原文 // 返回: map[int]bool 有效码点边界 func richTextBoundaries(value string) map[int]bool { @@ -156,7 +156,7 @@ func richTextBoundaries(value string) map[int]bool { return result } -// checkRange 检查半开范围及其字素边界。 +// checkRange 检查半开范围及其字素边界 // 入参: start 起点, end 终点 // 返回: error 无效范围 func (v RichText) checkRange(start, end int) error { @@ -170,7 +170,7 @@ func (v RichText) checkRange(start, end int) error { return nil } -// Slice 返回半开范围的独立片段,空范围保留插入位置的格式。 +// Slice 返回半开范围的独立片段,空范围保留插入位置的格式 // 入参: start 起点, end 终点 // 返回: RichText 独立片段, error 无效范围 func (v RichText) Slice(start, end int) (RichText, error) { @@ -193,7 +193,7 @@ func (v RichText) Slice(start, end int) (RichText, error) { return result, nil } -// sliceRuns 提取已校验范围,不保留空片段。 +// sliceRuns 提取已校验范围,不保留空片段 // 入参: start 起点, end 终点 // 返回: []RichTextRun 独立片段 func (v RichText) sliceRuns(start, end int) []RichTextRun { @@ -210,7 +210,7 @@ func (v RichText) sliceRuns(start, end int) []RichTextRun { return result } -// Replace 原子替换半开范围,空片段列表表示删除,相邻相同格式自动合并。 +// Replace 原子替换半开范围,空片段列表表示删除,相邻相同格式自动合并 // 入参: start 起点, end 终点, runs 替换片段 // 返回: error 范围或内容错误 func (v *RichText) Replace(start, end int, runs []RichTextRun) error { @@ -235,7 +235,7 @@ func (v *RichText) Replace(start, end int, runs []RichTextRun) error { return nil } -// ApplyFormat 原子修改半开范围的格式,空范围不改变正文。 +// ApplyFormat 原子修改半开范围的格式,空范围不改变正文 // 入参: start 起点, end 终点, patch 格式增量 // 返回: error 范围或格式错误 func (v *RichText) ApplyFormat(start, end int, patch TextFormatPatch) error { @@ -258,7 +258,7 @@ func (v *RichText) ApplyFormat(start, end int, patch TextFormatPatch) error { return v.Replace(start, end, part.Runs) } -// apply 应用显式给出的格式字段。 +// apply 应用显式给出的格式字段 // 入参: f 完整原格式 // 返回: TextFormat 修改后的格式 func (p TextFormatPatch) apply(f TextFormat) TextFormat { @@ -292,7 +292,7 @@ func (p TextFormatPatch) apply(f TextFormat) TextFormat { return f } -// normalizeRichText 合并相同格式,保留空文档的插入格式。 +// normalizeRichText 合并相同格式,保留空文档的插入格式 // 入参: v 完整富文本 // 返回: RichText 归一化副本 func normalizeRichText(v RichText) RichText { diff --git a/ofdgo_structure.go b/ofdgo_structure.go index 774673b..211e6e5 100644 --- a/ofdgo_structure.go +++ b/ofdgo_structure.go @@ -30,7 +30,7 @@ const structureTypeOfficial = "od" const structureNamespaceEGov2013 = "http://www.nits.org.cn/eGovDoc/2013" // Structure 保存自定义标引入口及结构树,单个文件解析失败时通过Error报告,不影响其他标引 -// CanBind表示已识别标引引用规则,包括公文标引及本库富文本标引,具体节点和目标仍由绑定接口校验 +// CanBind表示已识别标引引用规则,包括公文、富文本及发票标引,具体节点和目标仍由绑定接口校验 // Internal标记本库内部编辑标引,不删除或重排入口;界面跳过此项时必须保留原始数组索引 // 此标记不限制通用低级编辑接口,也不表示内部元数据内容已通过有效性校验 type Structure struct { @@ -45,7 +45,7 @@ type Structure struct { // StructureNode 保存标引节点的名称、属性、直接文本和有序子节点,不改变原始XML // Text按文档顺序汇总直接文本并保留空白,不包含子节点文本 -// References仅解释已识别公文标引或本库富文本标引中的ObjectRef;未知扩展保留结构,不推定其引用语义 +// References仅解释已识别公文、富文本或发票标引中的ObjectRef;未知扩展保留结构,不推定其引用语义 type StructureNode struct { Name string `json:"name"` Namespace string `json:"namespace,omitempty"` @@ -124,6 +124,7 @@ func (r *Reader) readStructureContext(ctx context.Context, tag CustomTag) (Struc } item.CanBind = structureOfficial(tag, item.Root.Namespace) item.Internal = tag.TypeID == richTextTagType && tag.NameSpace == richTextNamespace && item.Root.Namespace == richTextNamespace && item.Root.Name == "RichText" + item.Internal = item.Internal || tag.TypeID == invoiceTagType && tag.NameSpace == invoiceNamespace && item.Root.Namespace == invoiceNamespace && item.Root.Name == "Invoice" return item, nil } @@ -215,10 +216,13 @@ func parseStructure(ctx context.Context, data []byte, tag CustomTag) (*Structure return root, nil } -// structureOfficial 识别公文和本库富文本标引的明确引用规则,不推断未知行业语义 +// structureOfficial 识别公文、富文本及发票标引的明确引用规则,不推断未知行业语义 // 入参: tag 标引入口, namespace 内容根节点命名空间 // 返回: bool 是否使用公文引用规则 func structureOfficial(tag CustomTag, namespace string) bool { + if tag.TypeID == invoiceTagType && tag.NameSpace == invoiceNamespace && namespace == invoiceNamespace { + return true + } if tag.TypeID == richTextTagType && tag.NameSpace == richTextNamespace && namespace == richTextNamespace { return true }