mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 16:25:49 +08:00
946 lines
34 KiB
Go
946 lines
34 KiB
Go
// 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"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
if binding, ok := invoiceEDRMFactBinding(invoice, fact); ok {
|
|
value, err := invoiceBindingValue(invoice, binding)
|
|
raw := fact.Value
|
|
if binding.Field == "date" {
|
|
if date, err := time.Parse("2006年01月02日", raw); err == nil {
|
|
raw = date.Format("2006-01-02")
|
|
}
|
|
}
|
|
if err == nil && value == raw {
|
|
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
|
|
}
|
|
|
|
// invoiceEDRMFactBinding 按2019版完整路径识别摘要与原始字段的同一语义
|
|
// 入参: value 发票,fact 原始字段
|
|
// 返回: 公共字段关联及是否明确映射
|
|
func invoiceEDRMFactBinding(value Invoice, fact InvoiceFact) (InvoiceBinding, bool) {
|
|
if value.Standard != InvoiceStandardEDRM2019 && value.Standard != ofdNamespace2016 || fact.Namespace != value.Standard {
|
|
return InvoiceBinding{}, false
|
|
}
|
|
var path strings.Builder
|
|
nodePath := strings.ReplaceAll(fact.Source.NodePath, "{"+InvoiceStandardEDRM2019+"}", "{}")
|
|
if value.Standard == ofdNamespace2016 {
|
|
nodePath = strings.Replace(nodePath, "/root[1]", "/eInvoice[1]", 1)
|
|
nodePath = strings.ReplaceAll(nodePath, "/", "/{}")
|
|
}
|
|
for _, segment := range strings.Split(strings.TrimPrefix(nodePath, "/"), "/") {
|
|
name, index, ok := strings.Cut(segment, "[")
|
|
if !ok || !strings.HasPrefix(name, "{}") || !strings.HasSuffix(index, "]") {
|
|
return InvoiceBinding{}, false
|
|
}
|
|
path.WriteByte('/')
|
|
path.WriteString(strings.TrimPrefix(name, "{}"))
|
|
}
|
|
probe := Invoice{Items: []InvoiceItem{{}}}
|
|
field, _ := invoiceEDRMField(&probe, &probe.Items[0], path.String())
|
|
if field == nil {
|
|
return InvoiceBinding{}, false
|
|
}
|
|
*field = "mapped"
|
|
for _, candidate := range invoiceLayoutFields(probe) {
|
|
if candidate.value != "mapped" {
|
|
continue
|
|
}
|
|
if candidate.item < 0 {
|
|
return InvoiceBinding{Field: candidate.field, Item: -1}, true
|
|
}
|
|
for i, item := range value.Items {
|
|
for _, existing := range item.Fields {
|
|
if existing.Source.NodePath == fact.Source.NodePath {
|
|
return InvoiceBinding{Field: candidate.field, Item: i}, true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return InvoiceBinding{}, false
|
|
}
|
|
|
|
// 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)
|
|
if glyph == 0 {
|
|
return 0, fmt.Errorf("invoice font has no glyph for %U", 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
|
|
}
|
|
|
|
// layoutInvoiceReferenceSlots 按原图元框和基线分配变长字段,空TextCode保留原ID和原框,不借用跨列空间或改变引用
|
|
// 入参: ctx 取消上下文, bindings 有序图元槽位, before 原文字, value 新文字, font 字体覆盖, fontData 字体数据
|
|
// 返回: error 引用、字体、布局或容量错误
|
|
func (e *Editor) layoutInvoiceReferenceSlots(ctx context.Context, bindings []InvoiceBinding, before, value, font string, fontData []byte) error {
|
|
if len(bindings) == 0 || !utf8.ValidString(value) || !invoiceWriteText(value) || strings.ContainsAny(value, "\r\n\t") {
|
|
return fmt.Errorf("invoice slot layout requires valid single-flow text and existing references")
|
|
}
|
|
type slot struct {
|
|
binding InvoiceBinding
|
|
object GraphicObject
|
|
box Box
|
|
x, y float64
|
|
right float64
|
|
visible Box
|
|
}
|
|
slots := make([]slot, len(bindings))
|
|
mappings := make(map[string]map[rune]uint16)
|
|
var original strings.Builder
|
|
for i, binding := range bindings {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if binding.Page != bindings[0].Page {
|
|
return fmt.Errorf("invoice slot layout cannot infer flow across pages")
|
|
}
|
|
page, err := e.page(binding.Page)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
matches := 0
|
|
for _, layer := range page.Content.Layer {
|
|
for _, object := range layer.Objects {
|
|
if sameResourceID(editorObjectID(object), binding.ObjectID) {
|
|
matches++
|
|
}
|
|
}
|
|
}
|
|
object, err := e.Object(binding.Page, binding.ObjectID)
|
|
if err != nil || matches != 1 || object.Type != "TextObject" {
|
|
return fmt.Errorf("invoice slot requires one direct text object")
|
|
}
|
|
text := &object.TextObject
|
|
if text.CTM != "" || text.Clips != nil || text.ReadDirection != 0 || text.CharDirection != 0 || text.Italic || text.Weight > 400 || text.VScale != 0 || text.Decoration != "" || text.DrawParam != "" || text.Stroke != nil && *text.Stroke || !finite(text.HScale) || text.HScale < 0 || len(text.TextCode) == 0 {
|
|
return fmt.Errorf("invoice slot requires plain horizontal text without transforms")
|
|
}
|
|
box, err := creationBox(text.Boundary)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
x, err := strconv.ParseFloat(text.TextCode[0].X, 64)
|
|
if err != nil || !finite(x) || x < 0 || x > box.W {
|
|
return fmt.Errorf("invoice slot has an invalid horizontal origin")
|
|
}
|
|
y, err := strconv.ParseFloat(text.TextCode[0].Y, 64)
|
|
if err != nil || !finite(y) || y < 0 || y > box.H || !finite(text.Size) || text.Size <= 0 {
|
|
return fmt.Errorf("invoice slot has an invalid baseline or size")
|
|
}
|
|
for _, code := range text.TextCode {
|
|
if code.Index != "" || code.DeltaY != "" || code.Y != "" && code.Y != text.TextCode[0].Y {
|
|
return fmt.Errorf("invoice slot has unsupported glyph indexes or multiple baselines")
|
|
}
|
|
}
|
|
old := text.Text()
|
|
if strings.ContainsAny(old, "\r\n\t") {
|
|
return fmt.Errorf("invoice slot contains multiple text lines")
|
|
}
|
|
original.WriteString(old)
|
|
if font == "" && len(fontData) == 0 {
|
|
if err := validateTextGlyphs(*text, 1<<16); err != nil {
|
|
return err
|
|
}
|
|
if mappings[text.Font] == nil {
|
|
mappings[text.Font] = make(map[rune]uint16)
|
|
}
|
|
runes := []rune(old)
|
|
for _, transform := range text.CGTransform {
|
|
glyphs := strings.Fields(transform.Glyphs)
|
|
if transform.CodeCount != transform.GlyphCount || len(glyphs) != transform.CodeCount {
|
|
return fmt.Errorf("invoice combined glyph layout requires explicit Font or FontData")
|
|
}
|
|
for j, encoded := range glyphs {
|
|
glyph, err := strconv.ParseUint(encoded, 10, 16)
|
|
at := transform.CodePosition + j
|
|
if err != nil || glyph == 0 || at < 0 || at >= len(runes) {
|
|
return fmt.Errorf("invalid invoice glyph mapping")
|
|
}
|
|
if previous, exists := mappings[text.Font][runes[at]]; exists && previous != uint16(glyph) {
|
|
return fmt.Errorf("ambiguous invoice glyph mapping requires explicit Font or FontData")
|
|
}
|
|
mappings[text.Font][runes[at]] = uint16(glyph)
|
|
}
|
|
}
|
|
}
|
|
slots[i] = slot{binding: binding, object: object, box: box, x: x, y: y, right: box.W}
|
|
if i == 0 {
|
|
continue
|
|
}
|
|
previous := &slots[i-1]
|
|
step := math.Max(previous.object.TextObject.Size, text.Size)
|
|
if math.Abs(box.Y+y-previous.box.Y-previous.y) <= step*.1 {
|
|
start := box.X + x
|
|
if start <= previous.box.X+previous.x || start-(previous.box.X+previous.box.W) > step {
|
|
return fmt.Errorf("invoice references cross columns or reverse reading order")
|
|
}
|
|
previous.right = math.Min(previous.right, start-previous.box.X)
|
|
} else if math.Abs(box.X+x-slots[0].box.X-slots[0].x) > step*.25 || box.Y < previous.box.Y+previous.box.H-1e-6 || box.Y-(previous.box.Y+previous.box.H) > step*.5 {
|
|
return fmt.Errorf("invoice references do not form adjacent aligned text rows")
|
|
}
|
|
}
|
|
if original.String() != before {
|
|
return fmt.Errorf("invoice slot text changed")
|
|
}
|
|
reader, err := e.ReaderContext(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer reader.Close()
|
|
renderer := e.newRenderer(reader)
|
|
selected := make(map[string]int, len(slots))
|
|
for i, current := range slots {
|
|
selected[editorResourceID(current.binding.ObjectID)] = i
|
|
}
|
|
var obstacles []Box
|
|
page, err := e.page(bindings[0].Page)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, layer := range page.Content.Layer {
|
|
for _, object := range layer.Objects {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if object.Type != "TextObject" && object.Type != "ImageObject" && object.Type != "CompositeObject" && object.Type != "CompositeGraphicUnit" {
|
|
continue
|
|
}
|
|
box, err := renderer.ObjectBounds(object, layer.DrawParam)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot verify invoice slot neighbours: %w", err)
|
|
}
|
|
if index, ok := selected[editorResourceID(editorObjectID(object))]; ok {
|
|
slots[index].visible = box
|
|
} else if box.W > 0 && box.H > 0 {
|
|
obstacles = append(obstacles, box)
|
|
}
|
|
}
|
|
}
|
|
if len(fontData) != 0 {
|
|
var err error
|
|
font, err = e.AddFont(FontFile{Data: fontData}, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
runes, offset := []rune(value), 0
|
|
boundaries := richTextBoundaries(value)
|
|
var missing error
|
|
for i := range slots {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
current := &slots[i]
|
|
text := ¤t.object.TextObject
|
|
if font != "" {
|
|
text.Font = font
|
|
}
|
|
metrics, err := e.editorFont(text.Font)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
end := len(runes)
|
|
if i == 0 && (text.Text() == "¥" || text.Text() == "¥") && offset < end && string(runes[offset]) == text.Text() {
|
|
end = offset + 1
|
|
}
|
|
count, glyphErr, err := invoiceSlotText(ctx, text, runes[offset:end], offset, boundaries, current.x, current.y, current.right, current.box, current.visible, obstacles, metrics, mappings[text.Font])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count > 0 {
|
|
missing = nil
|
|
}
|
|
if glyphErr != nil {
|
|
missing = glyphErr
|
|
}
|
|
offset += count
|
|
}
|
|
if offset != len(runes) {
|
|
if missing != nil {
|
|
return fmt.Errorf("invoice slot requires Font or FontData with the missing characters: %w", missing)
|
|
}
|
|
return fmt.Errorf("invoice text exceeds original reference slot capacity")
|
|
}
|
|
layers := copyEditorPage(e.pages[bindings[0].Page]).Content.Layer
|
|
for _, current := range slots {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if err := validateEditorGeometry(current.object); err != nil {
|
|
return err
|
|
}
|
|
if current.object.TextObject.Text() != "" {
|
|
checked := cloneEditorData(current.object.TextObject)
|
|
if err := e.prepareText(&checked); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for i := range layers {
|
|
for j := range layers[i].Objects {
|
|
if sameResourceID(editorObjectID(layers[i].Objects[j]), current.binding.ObjectID) {
|
|
layers[i].Objects[j] = current.object
|
|
}
|
|
}
|
|
}
|
|
}
|
|
e.replaceLayers(bindings[0].Page, layers)
|
|
return ctx.Err()
|
|
}
|
|
|
|
// invoiceSlotText 用字体步进及轮廓填充单一原基线槽,保留可搜索文字和显式字形映射
|
|
// 入参: ctx 取消上下文, text 目标对象, value 剩余文字, offset 原文偏移, boundaries 字素边界, x和y 原点, right 右界, box 原框, visible 原可见范围, obstacles 外部障碍, metrics 字体, mapping 原映射
|
|
// 返回: int 已容纳码点数, error 可递延缺字, error 度量错误
|
|
func invoiceSlotText(ctx context.Context, text *TextObject, value []rune, offset int, boundaries map[int]bool, x, y, right float64, box, visible Box, obstacles []Box, metrics FontMetrics, mapping map[rune]uint16) (int, error, error) {
|
|
if metrics.UnitsPerEm() == 0 {
|
|
return 0, nil, fmt.Errorf("invalid invoice font units")
|
|
}
|
|
unit, cursor := text.Size/float64(metrics.UnitsPerEm()), x
|
|
scale := text.HScale
|
|
if scale == 0 {
|
|
scale = 1
|
|
}
|
|
count := 0
|
|
var missing error
|
|
var advances, glyphs []string
|
|
for i, char := range value {
|
|
if err := ctx.Err(); err != nil {
|
|
return 0, nil, err
|
|
}
|
|
glyph := mapping[char]
|
|
if glyph == 0 {
|
|
glyph = metrics.GlyphIndex(char)
|
|
}
|
|
if glyph == 0 {
|
|
missing = missingGlyphError(text.Font, []rune{char})
|
|
break
|
|
}
|
|
var bounds Box
|
|
var err error
|
|
if provider, ok := metrics.(FontGlyphBounds); ok {
|
|
bounds, err = provider.GlyphBounds(glyph, text.Size)
|
|
} else if outlines, ok := metrics.(FontOutlines); ok {
|
|
var outline GeometryPath
|
|
outline, err = outlines.GlyphOutline(glyph, text.Size)
|
|
if err == nil {
|
|
bounds, err = outline.Bounds()
|
|
}
|
|
} else {
|
|
return 0, nil, fmt.Errorf("invoice slot requires glyph outline metrics")
|
|
}
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
advance := float64(metrics.GlyphAdvance(glyph)) * unit * scale
|
|
bounds.X, bounds.W = bounds.X*scale, bounds.W*scale
|
|
if !finite(advance) || advance < 0 || !finite(bounds.X) || !finite(bounds.Y) || !finite(bounds.W) || !finite(bounds.H) {
|
|
return 0, nil, fmt.Errorf("invalid invoice glyph geometry")
|
|
}
|
|
if cursor+advance > right+1e-6 || cursor+bounds.X < -1e-6 || cursor+bounds.X+bounds.W > right+1e-6 || y+bounds.Y < -1e-6 || y+bounds.Y+bounds.H > box.H+1e-6 {
|
|
break
|
|
}
|
|
blocked := false
|
|
if bounds.W > 0 && bounds.H > 0 {
|
|
for _, obstacle := range obstacles {
|
|
left := math.Max(box.X+cursor+bounds.X, obstacle.X)
|
|
top := math.Max(box.Y+y+bounds.Y, obstacle.Y)
|
|
right := math.Min(box.X+cursor+bounds.X+bounds.W, obstacle.X+obstacle.W)
|
|
bottom := math.Min(box.Y+y+bounds.Y+bounds.H, obstacle.Y+obstacle.H)
|
|
if right > left+1e-6 && bottom > top+1e-6 && (visible.W <= 0 || visible.H <= 0 || left < visible.X-1e-6 || top < visible.Y-1e-6 || right > visible.X+visible.W+1e-6 || bottom > visible.Y+visible.H+1e-6) {
|
|
blocked = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if blocked {
|
|
break
|
|
}
|
|
advances = append(advances, ofdNumber(advance))
|
|
glyphs = append(glyphs, strconv.Itoa(int(glyph)))
|
|
cursor += advance
|
|
if boundaries[offset+i+1] {
|
|
count = i + 1
|
|
}
|
|
}
|
|
text.TextCode = []TextCode{{X: text.TextCode[0].X, Y: text.TextCode[0].Y, Value: escapeOFDText(string(value[:count]))}}
|
|
text.CGTransform, text.layout = nil, nil
|
|
if count > 0 {
|
|
text.TextCode[0].DeltaX = strings.Join(advances[:count-1], " ")
|
|
text.CGTransform = []CGTransform{{CodePosition: 0, CodeCount: count, GlyphCount: count, Glyphs: strings.Join(glyphs[:count], " ")}}
|
|
}
|
|
return count, missing, 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()
|
|
}
|