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) }