feat: dix all pdf problems. Validator now green

This commit is contained in:
2026-02-08 13:32:16 +01:00
parent 1196274498
commit 3fcbb55b8f
6 changed files with 588 additions and 13 deletions
+4 -3
View File
@@ -70,12 +70,13 @@ func AppendToPdf(
defer inputFile.Close()
var result io.Reader
result, err = addAttachment(inputFile, inputXmlFilePath, embedName, description)
result, err = addXmpData(inputFile)
if err != nil {
return err
}
result, err = addXmpData(result)
result, err = addAttachment(result, inputXmlFilePath, embedName, description)
if err != nil {
return err
}
@@ -120,7 +121,7 @@ func addAttachment(
return nil, err
}
err = doc.AddAttachment(attachment.Attachment{
err = AddAttachment(doc, attachment.Attachment{
Name: embedName,
Description: description,
MIMEType: "text/xml",
+317
View File
@@ -0,0 +1,317 @@
package lib
import (
"crypto/md5"
"errors"
"fmt"
"path/filepath"
"sort"
"time"
"gitlab.com/romalor/pdflib/pkg/components/attachment"
"gitlab.com/romalor/pdflib/pkg/core"
"gitlab.com/romalor/pdflib/pkg/pdf"
)
// AddAttachment embeds data as a file attachment.
// The att.Name field specifies the attachment name.
func AddAttachment(d *pdf.Document, att attachment.Attachment, data []byte) error {
if !d.IsWritable() || d.Writer() == nil {
return pdf.ErrNotWritable
}
if att.Name == "" {
return errors.New("attachment name cannot be empty")
}
// Apply defaults
modDate := att.ModDate
if modDate.IsZero() {
modDate = time.Now()
}
relationship := att.Relationship
if relationship == "" {
relationship = "Unspecified"
}
// Guess MIME type if not provided
mimeType := att.MIMEType
if mimeType == "" {
mimeType = guessMIMEType(att.Name)
}
// Calculate checksum
checksum := md5.Sum(data)
// Create embedded file stream
efStreamDict := core.NewDictionary()
efStreamDict.Set(core.Name("Type"), core.Name("EmbeddedFile"))
if mimeType != "" {
efStreamDict.Set(core.Name("Subtype"), core.Name(mimeType))
}
// Create Params dictionary
params := core.NewDictionary()
params.Set(core.Name("Size"), core.Integer(len(data)))
params.Set(core.Name("ModDate"), core.String(Format(modDate)))
params.Set(core.Name("CheckSum"), core.HexString(checksum[:]))
efStreamDict.Set(core.Name("Params"), params)
// Create the stream (compression is handled by the writer)
var efStream *core.Stream
if !att.NoCompress {
efStreamDict.Set(core.Name("Filter"), core.Name("FlateDecode"))
}
efStream = core.NewStreamFromBytes(efStreamDict, data)
efStreamRef := d.Writer().Add(efStream)
// Create EF (embedded files) dictionary
efDict := core.NewDictionary()
efDict.Set(core.Name("F"), efStreamRef)
efDict.Set(core.Name("UF"), efStreamRef) // Unicode-compatible version
// Create file specification dictionary
fileSpec := core.NewDictionary()
fileSpec.Set(core.Name("Type"), core.Name("Filespec"))
fileSpec.Set(core.Name("F"), core.String(att.Name))
fileSpec.Set(core.Name("UF"), core.String(att.Name)) // Unicode filename
fileSpec.Set(core.Name("EF"), efDict)
if att.Description != "" {
fileSpec.Set(core.Name("Desc"), core.String(att.Description))
}
if relationship != "Unspecified" {
fileSpec.Set(core.Name("AFRelationship"), core.Name(relationship))
}
fileSpecRef := d.Writer().Add(fileSpec)
// Update the EmbeddedFiles name tree
if err := addToEmbeddedFilesNameTree(d, att.Name, fileSpecRef); err != nil {
return fmt.Errorf("adding to embedded files tree: %w", err)
}
return nil
}
// addToEmbeddedFilesNameTree adds an entry to the EmbeddedFiles name tree.
func addToEmbeddedFilesNameTree(d *pdf.Document, name string, fileSpecRef core.Reference) error {
catalog, catalogRef, err := d.Resolver().Catalog()
if err != nil {
return err
}
// Try to get existing Names dictionary
var namesDict *core.Dictionary
var namesRef core.Reference
var namesIsRef bool
if namesObj, ok := catalog.Get(core.Name("Names")); ok {
if ref, isRef := namesObj.(core.Reference); isRef {
namesRef = ref
namesIsRef = true
}
namesDict, err = core.ResolveDict(d.Resolver(), namesObj)
if err != nil {
return fmt.Errorf("resolving Names dictionary: %w", err)
}
// Clone the dictionary
namesDict = namesDict.CloneDict()
} else {
// Create new Names dictionary
namesDict = core.NewDictionary()
}
// Try to get existing EmbeddedFiles name tree
var efDict *core.Dictionary
var efRef core.Reference
var efIsRef bool
if efObj, ok := namesDict.Get(core.Name("EmbeddedFiles")); ok {
if ref, isRef := efObj.(core.Reference); isRef {
efRef = ref
efIsRef = true
}
efDict, err = core.ResolveDict(d.Resolver(), efObj)
if err != nil {
return fmt.Errorf("resolving EmbeddedFiles dictionary: %w", err)
}
}
// Build new name tree with the added entry
var newNamesArray core.Array
if efDict != nil {
// Get existing entries
entries, err := collectNameTreeEntries(d, efDict)
if err != nil {
return fmt.Errorf("collecting existing entries: %w", err)
}
// Add new entry
entries = append(entries, nameTreeEntry{name: name, value: fileSpecRef})
// Sort by name
sort.Slice(entries, func(i, j int) bool {
return entries[i].name < entries[j].name
})
// Build names array
newNamesArray = make(core.Array, 0, len(entries)*2)
for _, entry := range entries {
newNamesArray = append(newNamesArray, core.String(entry.name), entry.value)
}
} else {
// Create new names array with single entry
newNamesArray = core.Array{core.String(name), fileSpecRef}
}
// Create new EmbeddedFiles dictionary
newEFDict := core.NewDictionary()
newEFDict.Set(core.Name("Names"), newNamesArray)
// Update or create EmbeddedFiles entry
if efIsRef {
if err := d.Writer().Modify(efRef, newEFDict); err != nil {
return fmt.Errorf("modifying EmbeddedFiles: %w", err)
}
} else {
namesDict.Set(core.Name("EmbeddedFiles"), newEFDict)
}
// Update or create Names dictionary
if namesIsRef {
if !efIsRef {
// We added a new EmbeddedFiles entry, need to update Names
if err := d.Writer().Modify(namesRef, namesDict); err != nil {
return fmt.Errorf("modifying Names: %w", err)
}
}
} else {
// Update catalog
newCatalog := catalog.CloneDict()
newCatalog.Set(core.Name("Names"), namesDict)
// Extend AF array
afArr := core.Array{fileSpecRef}
newCatalog.Set(core.Name("AF"), afArr)
if err := d.Writer().Modify(catalogRef, newCatalog); err != nil {
return fmt.Errorf("modifying catalog: %w", err)
}
}
return nil
}
// nameTreeEntry represents a name-value pair from a name tree.
type nameTreeEntry struct {
name string
value core.Object
}
// collectNameTreeEntries collects all entries from a name tree.
func collectNameTreeEntries(d *pdf.Document, dict *core.Dictionary) ([]nameTreeEntry, error) {
var entries []nameTreeEntry
// Check for Names array (leaf node)
if namesObj, ok := dict.Get(core.Name("Names")); ok {
namesArray, err := core.ResolveArray(d.Resolver(), namesObj)
if err != nil {
return nil, err
}
// Names array has pairs: [name1, value1, name2, value2, ...]
for i := 0; i+1 < len(namesArray); i += 2 {
var name string
switch n := namesArray[i].(type) {
case core.String:
name = string(n)
case core.HexString:
name = string(n)
default:
continue
}
entries = append(entries, nameTreeEntry{name: name, value: namesArray[i+1]})
}
}
// Check for Kids array (intermediate node)
if kidsObj, ok := dict.Get(core.Name("Kids")); ok {
kidsArray, err := core.ResolveArray(d.Resolver(), kidsObj)
if err != nil {
return nil, err
}
for _, kid := range kidsArray {
kidDict, err := core.ResolveDict(d.Resolver(), kid)
if err != nil {
continue
}
kidEntries, err := collectNameTreeEntries(d, kidDict)
if err != nil {
continue
}
entries = append(entries, kidEntries...)
}
}
return entries, nil
}
// guessMIMEType guesses the MIME type from a filename.
func guessMIMEType(filename string) string {
ext := filepath.Ext(filename)
if ext == "" {
return "application/octet-stream"
}
// Common MIME types
mimeTypes := map[string]string{
".pdf": "application/pdf",
".txt": "text/plain",
".html": "text/html",
".htm": "text/html",
".xml": "application/xml",
".json": "application/json",
".csv": "text/csv",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".zip": "application/zip",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
}
if mime, ok := mimeTypes[ext]; ok {
return mime
}
return "application/octet-stream"
}
// FormatPDFDate formats a time.Time as a PDF date string.
// The format is: D:YYYYMMDDHHmmSSOHH'mm'
// where O is the timezone indicator (+, or -) and HH'mm' is the offset.
func Format(t time.Time) string {
// Get timezone offset
_, offset := t.Zone()
offsetHours := offset / 3600
offsetMins := (offset % 3600) / 60
var sign byte = '+'
if offsetHours < 0 {
sign = '-'
offsetHours = -offsetHours
offsetMins = -offsetMins
}
return fmt.Sprintf("D:%04d%02d%02d%02d%02d%02d%c%02d'%02d'",
t.Year(), t.Month(), t.Day(),
t.Hour(), t.Minute(), t.Second(),
sign, offsetHours, offsetMins)
}
+101 -8
View File
@@ -11,27 +11,120 @@ const (
FacturXPrefix = "fx:"
)
type PDFAProperty struct {
Name string `xmp:"pdfaProperty:name"`
ValueType string `xmp:"pdfaProperty:valueType"`
Category string `xmp:"pdfaProperty:category"`
Description string `xmp:"pdfaProperty:description"`
}
type PDFASchema struct {
Schema string `xmp:"pdfaSchema:schema"`
NamespaceURI string `xmp:"pdfaSchema:namespaceURI"`
Prefix string `xmp:"pdfaSchema:prefix"`
Property []PDFAProperty `xmp:"pdfaSchema:property"`
}
type PDFAExtensionData struct {
Schemas [1]PDFASchema `xmp:"pdfaExtension:schemas,bag"`
}
type PDFAExtensionModel struct {
PDFAExtensionData
xmpbase.XmpBase
}
func (x PDFAExtensionModel) Can(nsName string) bool {
return nsName == "pdfaExtension"
}
func (x PDFAExtensionModel) Namespaces() xmp.NamespaceList {
return xmp.NamespaceList{
{
Name: "pdfaSchema",
URI: "http://www.aiim.org/pdfa/ns/schema#",
Factory: defaultFactory,
},
{
Name: "pdfaExtension",
URI: "http://www.aiim.org/pdfa/ns/extension/",
Factory: defaultFactory,
},
{
Name: "pdfaProperty",
URI: "http://www.aiim.org/pdfa/ns/property#",
Factory: defaultFactory,
},
}
}
func defaultFactory(name string) xmp.Model {
return nil
}
func addFacturXXmpData(data []byte) ([]byte, error) {
// --- Register Factur-X namespace ---
xmp.Register(&xmp.Namespace{
Name: "fx",
URI: FacturXUri,
Factory: defaultFactory,
})
doc := &xmp.Document{}
err := xmp.Unmarshal(data, doc)
if err != nil {
return nil, err
}
// --- Register Factur-X namespace ---
ns := &xmp.Namespace{
Name: "fx",
URI: FacturXUri,
Factory: func(name string) xmp.Model {
return &xmpbase.XmpBase{}
model := &PDFAExtensionModel{
PDFAExtensionData: PDFAExtensionData{
Schemas: [1]PDFASchema{
{
Schema: "Factur-X PDFA Extension Schema",
NamespaceURI: "urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#",
Prefix: "fx",
Property: []PDFAProperty{
{
Name: "DocumentType",
ValueType: "Text",
Category: "external",
Description: "Factur-X document type",
},
{
Name: "DocumentFileName",
ValueType: "Text",
Category: "external",
Description: "Attached XML file name",
},
{
Name: "Version",
ValueType: "Text",
Category: "external",
Description: "Factur-X version",
},
{
Name: "ConformanceLevel",
ValueType: "Text",
Category: "external",
Description: "Factur-X profile",
},
},
},
},
},
}
xmp.Register(ns)
_, err = doc.MakeModel(ns)
_, err = doc.AddModel(model)
if err != nil {
return nil, err
}
doc.FindModel(&xmp.Namespace{
Name: "",
URI: "",
Factory: nil,
})
// --- Set Factur-X properties ---
err = doc.SetPath(xmp.PathValue{
Path: FacturXPrefix + "ConformanceLevel",