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 + ``)
+ 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%s>", name, invoiceWriteEscape(*p), name)
+ }
+ }
+ fmt.Fprintf(&b, "%s>", 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%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(""+name+">")...)
+ 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
}