diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index a2fb6a4..00f4699 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -3755,6 +3755,7 @@ async function changeDocument(name, item, ...args) { } catch (err) { refreshManagement = false; if (name === "ofdgoChangeFontBinding") err.message = fontBindingError(err.message); + if (name === "ofdgoChangeInvoice") err.message = invoiceIssues([err.message]); if (openSeq === state.openSeq) { if (settingsElements.Panel.open) { settingsElements.Status.textContent = err.message; @@ -5386,7 +5387,7 @@ function settingsButton(label, action, disabled = false, id = "", container = se if (id) button.id = `settings${id}`; button.addEventListener("click", async () => { try { await action(); } - catch (error) { settingsElements.Status.textContent = error.message; } + catch (error) { settingsElements.Status.textContent = settings.invoice ? invoiceIssues([error.message]) : error.message; } }); container.append(button); return button; @@ -5728,13 +5729,18 @@ function invoiceIssues(issues, details = false) { "unsupported invoice XML root or version": "暂不支持此发票格式或版本", "unsupported eInvoice namespace or version": "暂不支持此发票格式或版本", "unsupported EInvoice version": "暂不支持此发票版本", - "EInvoice requires a unique versioned Header": "发票头信息不完整" + "EInvoice requires a unique versioned Header": "发票头信息不完整", + "invoice text exceeds original reference slot capacity": "原框空间不足,请缩短文字", + "invoice slot requires Font or FontData with the missing characters:": "字体缺字,请更换字体", + "invoice references cross columns or reverse reading order": "图元跨列,无法重排", + "invoice references do not form adjacent aligned text rows": "图元未对齐,无法重排" }; return [...new Set((issues || []).map(issue => { const message = typeof issue === "string" ? issue : issue.message || issue.code; - if (typeof issue === "string") return details ? message : messages[message] || message; + const translated = messages[message] || Object.entries(messages).find(([prefix]) => prefix.endsWith(":") && message?.startsWith(prefix))?.[1] || message; + if (typeof issue === "string") return details ? message : translated; if (details) return [issue.source?.fileLoc || issue.source?.id || issue.source?.kind, issue.source?.nodePath, issue.field, message].filter(Boolean).join(":"); - return [issue.field, codes[issue.code] || messages[message] || message].filter(Boolean).join(":"); + return [issue.field, codes[issue.code] || translated].filter(Boolean).join(":"); }).filter(Boolean))].join("\n"); } @@ -6109,6 +6115,12 @@ function renderInvoiceFields(container, draft) { invoiceDirty(draft, "fields"); }; invoiceInput(container, "数值", "FactValue", change?.value ?? fact.value, value => update("value", value), !writable, "textarea"); + if (writable && !fresh && draft.invoice.standard === invoiceReferenceStandard) { + const fonts = state.doc.fonts || []; + const libraryFonts = fontManager.editorFonts().filter(font => !font.disabled); + settingsSelect(container, "字体", "InvoiceFactFont", [["", "原字体"], ...fonts.map(font => [font.id, font.fontName || font.familyName || font.id]), ...libraryFonts.map(font => [`file:${font.id}`, font.fullName || font.name])], change?.font || "", value => update("font", value)); + settingsButton("添加", () => openFontFile(el.fontInput), false, "InvoiceFactAddFont", container); + } invoiceInput(container, "空值", "FactNil", change?.nil ?? fact.nil, value => update("nil", value), !writable || draft.invoice.standard === invoiceReferenceStandard, "checkbox"); if (!fresh) invoiceInput(container, "删除", "FactDelete", change?.delete || false, value => update("delete", value), !writable || draft.invoice.standard === invoiceReferenceStandard, "checkbox"); else if (state.editing) settingsButton("删除", () => { @@ -6350,23 +6362,41 @@ async function applyInvoiceDraft(draft, synchronize = draft.synchronize) { } async function prepareInvoiceRequest(draft, request) { - if (request.operation !== "create" || draft.invoice.standard !== invoiceReferenceStandard) return true; - if (!draft.layout.font && !draft.fontChoice?.startsWith("file:")) { + if (draft.invoice.standard !== invoiceReferenceStandard) return true; + const creating = request.operation === "create"; + const patches = (request.operation === "batch" ? request.operations : [request]).filter(operation => operation.operation === "patch"); + if (!creating && !patches.length) return true; + if (creating && !draft.layout.font && !draft.fontChoice?.startsWith("file:")) { invoicePanel.tab = "appearance"; draft.layoutOpen = true; renderDocumentSettings(); throw new Error("请选择标引票面字体"); } const sequence = settings.sequence, openSeq = state.openSeq; + const doc = state.doc, scope = JSON.stringify(invoiceScope()); const snapshot = JSON.stringify(request), fontChoice = draft.fontChoice; + for (const patch of patches) patch.changes = structuredClone(patch.changes); settingsElements.Form.inert = true; try { - if (fontChoice?.startsWith("file:")) { + if (creating && fontChoice?.startsWith("file:")) { const font = fontManager.editorFonts().find(value => `file:${value.id}` === fontChoice && !value.disabled); if (!font) throw new Error("所选字体不可用"); request.fontData = invoiceBase64(await readTextFont(font)); } - if (sequence !== settings.sequence || openSeq !== state.openSeq || invoiceCurrent() !== draft || !settingsElements.Panel.open || !settings.invoice) return false; + const fonts = new Map(); + for (const patch of patches) { + for (const change of patch.changes) { + if (!change.font?.startsWith("file:")) continue; + if (!fonts.has(change.font)) { + const font = fontManager.editorFonts().find(value => `file:${value.id}` === change.font && !value.disabled); + if (!font) throw new Error("所选字体不可用"); + fonts.set(change.font, invoiceBase64(await readTextFont(font))); + } + change.fontData = fonts.get(change.font); + delete change.font; + } + } + if (sequence !== settings.sequence || openSeq !== state.openSeq || doc !== state.doc || scope !== JSON.stringify(invoiceScope()) || invoiceCurrent() !== draft || !settingsElements.Panel.open || !settings.invoice) return false; if (draft.fontChoice !== fontChoice || JSON.stringify(buildInvoiceRequest(draft)) !== snapshot) throw new Error("内容已变更,请重新应用"); return true; } finally { @@ -6395,7 +6425,7 @@ function buildInvoiceRequest(draft) { else { const operations = []; if (draft.dirty.has("model")) operations.push({ operation: "set", index: draft.entry.index, invoice: draft.invoice }); - if (draft.dirty.has("fields")) operations.push({ operation: "patch", index: draft.entry.index, changes: [...draft.changes.values()] }); + if (draft.dirty.has("fields")) operations.push({ operation: "patch", index: draft.entry.index, changes: structuredClone([...draft.changes.values()]) }); if (draft.dirty.has("bindings")) operations.push({ operation: "bindings", index: draft.entry.index, bindings: draft.bindings }); Object.assign(request, operations.length === 1 ? operations[0] : { operation: "batch", operations }); } diff --git a/ofdgo_invoice_layout.go b/ofdgo_invoice_layout.go index 64cacae..09f5705 100644 --- a/ofdgo_invoice_layout.go +++ b/ofdgo_invoice_layout.go @@ -24,6 +24,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" ) // InvoiceLayout 指定发票信息页的字体资源及毫米尺寸,不代表税务机关的固定票样 @@ -537,6 +538,306 @@ func invoiceLayoutText(text *TextObject, value string, metrics FontMetrics) (flo 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表示无 diff --git a/ofdgo_invoice_source.go b/ofdgo_invoice_source.go index 8e1b003..f3667e1 100644 --- a/ofdgo_invoice_source.go +++ b/ofdgo_invoice_source.go @@ -161,7 +161,7 @@ func (r *Reader) readInvoiceXML(ctx context.Context, data []byte, source Invoice value := strings.TrimSpace(fact.Value) if numeric { value = strings.TrimSpace(strings.TrimLeft(value, "¥¥")) - if invoiceDecimal(value) == nil { + if value != "" && invoiceDecimal(value) == nil { report.Issues = append(report.Issues, InvoiceIssue{Code: "invalid-decimal", Message: "invalid invoice amount", Source: factSource, Field: nodePath}) return nil } @@ -656,6 +656,7 @@ func (e *Editor) editInvoiceReferences(ctx context.Context, source InvoiceSource var selected []InvoiceBinding var singles []InvoiceBinding joint := make(map[int][]InvoiceBinding) + flow := make(map[int]InvoiceFieldChange) editable := invoiceReferenceEditability(invoice) seen := make(map[int]bool) for _, change := range changes { @@ -667,7 +668,7 @@ func (e *Editor) editInvoiceReferences(ctx context.Context, source InvoiceSource return fmt.Errorf("object reference edit requires a unique fact and matching expected text") } seen[index] = true - if change.Value == invoice.Facts[index].Value { + if change.Value == invoice.Facts[index].Value && change.Font == "" && len(change.FontData) == 0 { continue } if !editable[index] { @@ -678,13 +679,13 @@ func (e *Editor) editInvoiceReferences(ctx context.Context, source InvoiceSource if err != nil { return err } - if binding >= 0 { + if binding >= 0 && change.Font == "" && len(change.FontData) == 0 && value != "" { selected = append(selected, bindings[binding]) singles = append(singles, bindings[binding]) } else { fact := invoice.Facts[index] - if utf8.RuneCountInString(value) != utf8.RuneCountInString(fact.Value) || value == "" { - return fmt.Errorf("multiple invoice references require equal-length positioned replacement") + if utf8.RuneCountInString(value) != utf8.RuneCountInString(fact.Value) || value == "" || change.Font != "" || len(change.FontData) != 0 { + flow[index] = change } for _, ref := range fact.References { page := slices.IndexFunc(next.pages, func(page PageContent) bool { return sameResourceID(page.ID, ref.PageID) }) @@ -742,6 +743,12 @@ func (e *Editor) editInvoiceReferences(ctx context.Context, source InvoiceSource if len(joint[i]) == 0 { continue } + if change, ok := flow[i]; ok { + if err := next.layoutInvoiceReferenceSlots(ctx, joint[i], fact.Value, invoice.Facts[i].Value, change.Font, change.FontData); err != nil { + return err + } + continue + } value := []rune(invoice.Facts[i].Value) offset := 0 var original strings.Builder @@ -813,6 +820,9 @@ func (e *Editor) editInvoiceReferences(ctx context.Context, source InvoiceSource // 入参: invoice 原发票, fact 原事实, value 新业务值 // 返回: string 显示文字, error 值格式错误 func invoiceReferenceDisplay(invoice Invoice, fact InvoiceFact, value string) (string, error) { + if value == "" { + return "", nil + } binding, ok := invoiceEDRMFactBinding(invoice, fact) if !ok { return value, nil diff --git a/ofdgo_invoice_write.go b/ofdgo_invoice_write.go index b06cf4e..6cc965b 100644 --- a/ofdgo_invoice_write.go +++ b/ofdgo_invoice_write.go @@ -58,9 +58,12 @@ type InvoiceFieldDescriptor struct { // InvoiceFieldChange 修改已有叶节点,Revision为源XML的SHA256十六进制摘要 // 未提供Revision时必须提供ExpectedValue,ExpectedNil缺省表示原节点非空值 +// Font和FontData仅用于图元引用字段的票面字体替换,FontData优先,不改变字号 type InvoiceFieldChange struct { Source InvoiceSource `json:"source"` Value string `json:"value"` + Font string `json:"font,omitempty"` + FontData []byte `json:"fontData,omitempty"` Nil bool `json:"nil,omitempty"` Delete bool `json:"delete,omitempty"` Revision string `json:"revision,omitempty"` @@ -984,6 +987,9 @@ func PatchInvoice(ctx context.Context, data []byte, changes []InvoiceFieldChange seen := map[string]bool{} var patches []invoiceXMLPatch for _, change := range changes { + if change.Font != "" || len(change.FontData) != 0 { + return nil, fmt.Errorf("invoice font replacement requires object references") + } path := change.Source.NodePath s, ok := spans[path] if !ok || !allowed[path] || len(s.node.children) > 0 || seen[path] {