Repository: horstoeko/zugferd (php)

Issue:
[QUESTION] Additional attachments in PDF
I would like to thank you very much for your library, it is a really big help (I'm a German user). I have not yet been able to find any information here on whether I can also embed attachments such as timesheets (which I usually embed in the XRechnung) directly in ZUGFeRD. Unfortunately, I don't understand anything about PDF/A, but if I understand the ZUGFeRD specification correctly, you seem to be able to attach additional documents with AFRelationship/Supplement.
Hence my question: Does your library support adding and reading additional attachments from the PDF?

Pull request:
Pdfattachments
# Description

An option has been created to attach additional attachments to a PDF

See [the documentation](https://github.com/horstoeko/zugferd/wiki/Creating-PDF-Documents#add-addtional-attachments-to-pdf)

Fixes #153 


Patch:
diff --git a/composer.json b/composer.json
index d07dcc3..bf3f697 100644
--- a/composer.json
+++ b/composer.json
@@ -32,7 +32,7 @@
         }
     },
     "require": {
-        "php": ">=7.3",
+        "php": "^7.3|^7.4|^8",
         "jms/serializer": "^3",
         "goetas-webservices/xsd2php-runtime": "^0.2.13",
         "ext-simplexml": "*",
diff --git a/examples/En16931PdfBuilder.php b/examples/En16931PdfBuilder.php
index 4f6bf7f..97acce6 100644
--- a/examples/En16931PdfBuilder.php
+++ b/examples/En16931PdfBuilder.php
@@ -55,4 +55,8 @@ if (!file_exists($existingPdf)) {
 }
 
 $documentPdfBuilder = new ZugferdDocumentPdfBuilder($document, $existingPdf);
-$documentPdfBuilder->setAttachmentRelationshipType('Alternative')->generateDocument()->saveDocument($mergeToPdf);
+$documentPdfBuilder
+    ->setAttachmentRelationshipType('Alternative')
+    ->attachAdditionalFileByRealFile(dirname(__FILE__) . '/addattchment.txt', '', 'Dummy')
+    ->generateDocument()
+    ->saveDocument($mergeToPdf);
diff --git a/examples/addattchment.txt b/examples/addattchment.txt
new file mode 100644
index 0000000..41bfe81
--- /dev/null
+++ b/examples/addattchment.txt
@@ -0,0 +1,1 @@
+Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.
\ No newline at end of file
diff --git a/src/ZugferdDocumentPdfBuilderAbstract.php b/src/ZugferdDocumentPdfBuilderAbstract.php
index 05f212b..b0355b6 100644
--- a/src/ZugferdDocumentPdfBuilderAbstract.php
+++ b/src/ZugferdDocumentPdfBuilderAbstract.php
@@ -13,10 +13,15 @@ use DOMXpath;
 use Throwable;
 use TypeError;
 use DOMDocument;
-use horstoeko\zugferd\ZugferdSettings;
-use horstoeko\zugferd\ZugferdPdfWriter;
-use horstoeko\zugferd\ZugferdPackageVersion;
+use horstoeko\mimedb\MimeDb;
+use horstoeko\stringmanagement\FileUtils;
 use horstoeko\zugferd\codelists\ZugferdInvoiceType;
+use horstoeko\zugferd\exception\ZugferdFileNotFoundException;
+use horstoeko\zugferd\exception\ZugferdUnknownMimetype;
+use horstoeko\zugferd\ZugferdPackageVersion;
+use horstoeko\zugferd\ZugferdPdfWriter;
+use horstoeko\zugferd\ZugferdSettings;
+use InvalidArgumentException;
 use setasign\Fpdi\PdfParser\StreamReader as PdfStreamReader;
 
 /**
@@ -31,6 +36,16 @@ use setasign\Fpdi\PdfParser\StreamReader as PdfStreamReader;
  */
 abstract class ZugferdDocumentPdfBuilderAbstract
 {
+    /**
+     * Constants for Relationship types
+     * 'Data', 'Alternative', 'Source', 'Supplement', 'Unspecified'
+     */
+    public const AF_RELATIONSHIP_DATA = "Data";
+    public const AF_RELATIONSHIP_ALTERNATIVE = "Alternative";
+    public const AF_RELATIONSHIP_SOURCE = "Source";
+    public const AF_RELATIONSHIP_SUPPLEMENT = "Supplement";
+    public const AF_RELATIONSHIP_UNSPECIFIED = "Unspecified";
+
     /**
      * Additional creator tool (e.g. the ERP software that called the PHP library)
      *
@@ -59,6 +74,13 @@ abstract class ZugferdDocumentPdfBuilderAbstract
      */
     private $pdfData = "";
 
+    /**
+     * List of files which should be additionally attached to PDF
+     *
+     * @var array
+     */
+    private $additionalFilesToAttach = [];
+
     /**
      * Constructor
      *
@@ -160,8 +182,8 @@ abstract class ZugferdDocumentPdfBuilderAbstract
      */
     public function setAttachmentRelationshipType(string $relationshipType)
     {
-        if (!in_array($relationshipType, ['Data', 'Alternative', 'Source'])) {
-            $relationshipType = 'Data';
+        if (!in_array($relationshipType, [static::AF_RELATIONSHIP_DATA, static::AF_RELATIONSHIP_ALTERNATIVE, static::AF_RELATIONSHIP_SOURCE])) {
+            $relationshipType = static::AF_RELATIONSHIP_DATA;
         }
 
         $this->attachmentRelationshipType = $relationshipType;
@@ -187,7 +209,7 @@ abstract class ZugferdDocumentPdfBuilderAbstract
      */
     public function setAttachmentRelationshipTypeToData()
     {
-        return $this->setAttachmentRelationshipType('Data');
+        return $this->setAttachmentRelationshipType(static::AF_RELATIONSHIP_DATA);
     }
 
     /**
@@ -197,7 +219,7 @@ abstract class ZugferdDocumentPdfBuilderAbstract
      */
     public function setAttachmentRelationshipTypeToAlternative()
     {
-        return $this->setAttachmentRelationshipType('Alternative');
+        return $this->setAttachmentRelationshipType(static::AF_RELATIONSHIP_ALTERNATIVE);
     }
 
     /**
@@ -207,7 +229,106 @@ abstract class ZugferdDocumentPdfBuilderAbstract
      */
     public function setAttachmentRelationshipTypeToSource()
     {
-        return $this->setAttachmentRelationshipType('Source');
+        return $this->setAttachmentRelationshipType(static::AF_RELATIONSHIP_SOURCE);
+    }
+
+    /**
+     * Attach an additional file to PDF. The file that is specified in $fullFilename
+     * must exists
+     *
+     * @param  string $fullFilename
+     * @param  string $displayName
+     * @param  string $relationshipType
+     * @return static
+     * @throws ZugferdFileNotFoundException
+     * @throws ZugferdUnknownMimetype
+     */
+    public function attachAdditionalFileByRealFile(string $fullFilename, string $displayName = "", string $relationshipType = "")
+    {
+        // Checks that the file really exists
+
+        if (empty($fullFilename)) {
+            throw new InvalidArgumentException("You must specify a filename for the content to attach");
+        }
+
+        if (!file_exists($fullFilename)) {
+            throw new ZugferdFileNotFoundException($fullFilename);
+        }
+
+        // Load content
+
+        $content = file_get_contents($fullFilename);
+
+        // Add attachment
+
+        $this->attachAdditionalFileByContent(
+            $content,
+            $fullFilename,
+            $displayName,
+            $relationshipType,
+        );
+
+        return $this;
+    }
+
+    /**
+     * Attach an additional file to PDF by a content string
+     *
+     * @param  string $content
+     * @param  string $filename
+     * @param  string $displayName
+     * @param  string $relationshipType
+     * @return static
+     */
+    public function attachAdditionalFileByContent(string $content, string $filename, string $displayName = "", string $relationshipType = "")
+    {
+        // Check content. The content must not be empty
+
+        if (empty($content)) {
+            throw new InvalidArgumentException("You must specify a content to attach");
+        }
+
+        // Check filename. The filename must not be empty
+
+        if (empty($filename)) {
+            throw new InvalidArgumentException("You must specify a filename for the content to attach");
+        }
+
+        // Mimetype for the file must exist
+
+        $mimeType = (new MimeDb())->findFirstMimeTypeByExtension(FileUtils::getFileExtension($filename));
+
+        if (is_null($mimeType)) {
+            throw new ZugferdUnknownMimetype();
+        }
+
+        // Sanatize relationship type
+
+        if (empty($relationshipType)) {
+            $relationshipType = static::AF_RELATIONSHIP_SUPPLEMENT;
+        }
+
+        if (!in_array($relationshipType, [static::AF_RELATIONSHIP_DATA, static::AF_RELATIONSHIP_ALTERNATIVE, static::AF_RELATIONSHIP_SOURCE, static::AF_RELATIONSHIP_SUPPLEMENT, static::AF_RELATIONSHIP_UNSPECIFIED])) {
+            $relationshipType = static::AF_RELATIONSHIP_SUPPLEMENT;
+        }
+
+        // Sanatize displayname
+
+        if (empty($displayName)) {
+            $displayName = FileUtils::getFilenameWithExtension($filename);
+        }
+
+        // Add to attachment list
+
+        $this->additionalFilesToAttach[] = [
+            PdfStreamReader::createByString($content),
+            FileUtils::getFilenameWithExtension($filename),
+            $displayName,
+            $relationshipType,
+            str_replace('/', '#2F', $mimeType)
+        ];
+
+        return $this;
     }
 
     /**
@@ -242,7 +363,7 @@ abstract class ZugferdDocumentPdfBuilderAbstract
 
         $pdfDataRef = null;
 
-        if ($this->pdfDataIsFile($this->pdfData)) {
+        if ($this->isFile($this->pdfData)) {
             $pdfDataRef = $this->pdfData;
         } elseif (is_string($this->pdfData)) {
             $pdfDataRef = PdfStreamReader::createByString($this->pdfData);
@@ -262,6 +383,20 @@ abstract class ZugferdDocumentPdfBuilderAbstract
             'text#2Fxml'
         );
 
+        // Add additional attachments
+
+        foreach ($this->additionalFilesToAttach as $fileToAttach) {
+            $this->pdfWriter->attach(
+                $fileToAttach[0],
+                $fileToAttach[1],
+                $fileToAttach[2],
+                $fileToAttach[3],
+                $fileToAttach[4],
+            );
+        }
+
+        // Set flag to always show the attachment pane
+
         $this->pdfWriter->openAttachmentPane();
 
         // Copy pages from the original PDF
@@ -401,7 +536,7 @@ abstract class ZugferdDocumentPdfBuilderAbstract
      * @param  string $pdfData
      * @return boolean
      */
-    private function pdfDataIsFile($pdfData): bool
+    private function isFile($pdfData): bool
     {
         try {
             return @is_file($pdfData);
diff --git a/src/ZugferdObjectHelper.php b/src/ZugferdObjectHelper.php
index ba459a7..aff4697 100644
--- a/src/ZugferdObjectHelper.php
+++ b/src/ZugferdObjectHelper.php
@@ -14,6 +14,7 @@ use horstoeko\mimedb\MimeDb;
 use horstoeko\stringmanagement\FileUtils;
 use horstoeko\stringmanagement\StringUtils;
 use horstoeko\zugferd\exception\ZugferdUnknownDateFormatException;
+use horstoeko\zugferd\exception\ZugferdUnsupportedMimetype;
 use horstoeko\zugferd\ZugferdProfileResolver;
 
 /**
@@ -525,7 +526,7 @@ class ZugferdObjectHelper
                         $this->getBinaryObjectType($content, $mimeTypesSupported[0], FileUtils::getFilenameWithExtension($binarydatafilename))
                     );
                 } else {
-                    throw new \Exception(sprintf("Invalid attachment. Mimetype not supported"));
+                    throw new ZugferdUnsupportedMimetype();
                 }
             }
         }
diff --git a/src/exception/ZugferdExceptionCodes.php b/src/exception/ZugferdExceptionCodes.php
index d0cba60..c9909f8 100644
--- a/src/exception/ZugferdExceptionCodes.php
+++ b/src/exception/ZugferdExceptionCodes.php
@@ -27,6 +27,8 @@ class ZugferdExceptionCodes
     public const NOVALIDATTACHMENTFOUNDINPDF = -1105;
     public const UNKNOWNPROFILEPARAMETER = -1106;
     public const UNKNOWNSYNTAX = -1107;
+    public const UNKNOWNMIMETYPE = -1108;
+    public const UNSUPPORTEDMIMETYPE = -1109;
     public const FILENOTFOUND = -2000;
     public const FILENOTREADABLE = -2001;
 }
diff --git a/src/exception/ZugferdUnknownMimetype.php b/src/exception/ZugferdUnknownMimetype.php
new file mode 100644
index 0000000..2a2314b
--- /dev/null
+++ b/src/exception/ZugferdUnknownMimetype.php
@@ -0,0 +1,34 @@
+<?php
+
+/**
+ * This file is a part of horstoeko/zugferd.
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace horstoeko\zugferd\exception;
+
+use Throwable;
+
+/**
+ * Class representing an exception for unknown mimetype
+ *
+ * @category Zugferd
+ * @package  Zugferd
+ * @author   D. Erling <horstoeko@erling.com.de>
+ * @license  https://opensource.org/licenses/MIT MIT
+ * @link     https://github.com/horstoeko/zugferd
+ */
+class ZugferdUnknownMimetype extends ZugferdBaseException
+{
+    /**
+     * Constructor
+     *
+     * @param Throwable|null $previous
+     */
+    public function __construct(?Throwable $previous = null)
+    {
+        parent::__construct("No mimetype found", ZugferdExceptionCodes::UNKNOWNMIMETYPE, $previous);
+    }
+}
diff --git a/src/exception/ZugferdUnsupportedMimetype.php b/src/exception/ZugferdUnsupportedMimetype.php
new file mode 100644
index 0000000..d121e03
--- /dev/null
+++ b/src/exception/ZugferdUnsupportedMimetype.php
@@ -0,0 +1,34 @@
+<?php
+
+/**
+ * This file is a part of horstoeko/zugferd.
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace horstoeko\zugferd\exception;
+
+use Throwable;
+
+/**
+ * Class representing an exception for unsupported mimetype
+ *
+ * @category Zugferd
+ * @package  Zugferd
+ * @author   D. Erling <horstoeko@erling.com.de>
+ * @license  https://opensource.org/licenses/MIT MIT
+ * @link     https://github.com/horstoeko/zugferd
+ */
+class ZugferdUnsupportedMimetype extends ZugferdBaseException
+{
+    /**
+     * Constructor
+     *
+     * @param Throwable|null $previous
+     */
+    public function __construct(?Throwable $previous = null)
+    {
+        parent::__construct("Mimetype not supported", ZugferdExceptionCodes::UNSUPPORTEDMIMETYPE, $previous);
+    }
+}


Test patch:
diff --git a/tests/assets/dummy_attachment_1.dummy b/tests/assets/dummy_attachment_1.dummy
new file mode 100644
index 0000000..938179b
--- /dev/null
+++ b/tests/assets/dummy_attachment_1.dummy
@@ -0,0 +1,1 @@
+Dummy
\ No newline at end of file
diff --git a/tests/assets/txt_addattachment_1.txt b/tests/assets/txt_addattachment_1.txt
new file mode 100644
index 0000000..41bfe81
--- /dev/null
+++ b/tests/assets/txt_addattachment_1.txt
@@ -0,0 +1,1 @@
+Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.
\ No newline at end of file
diff --git a/tests/testcases/PdfBuilderEn16931Test.php b/tests/testcases/PdfBuilderEn16931Test.php
index 55a48f5..bd49836 100644
--- a/tests/testcases/PdfBuilderEn16931Test.php
+++ b/tests/testcases/PdfBuilderEn16931Test.php
@@ -2,16 +2,20 @@
 
 namespace horstoeko\zugferd\tests\testcases;
 
-use horstoeko\zugferd\codelists\ZugferdPaymentMeans;
-use horstoeko\zugferd\exception\ZugferdFileNotFoundException;
+use InvalidArgumentException;
 use horstoeko\zugferd\tests\TestCase;
-use horstoeko\zugferd\tests\traits\HandlesXmlTests;
-use horstoeko\zugferd\ZugferdDocumentBuilder;
-use horstoeko\zugferd\ZugferdDocumentPdfBuilder;
-use horstoeko\zugferd\ZugferdDocumentPdfReader;
 use horstoeko\zugferd\ZugferdProfiles;
-use setasign\Fpdi\PdfParser\PdfParserException;
+use setasign\Fpdi\PdfParser\StreamReader;
 use Smalot\PdfParser\Parser as PdfParser;
+use horstoeko\zugferd\ZugferdPackageVersion;
+use horstoeko\zugferd\ZugferdDocumentBuilder;
+use setasign\Fpdi\PdfParser\PdfParserException;
+use horstoeko\zugferd\ZugferdDocumentPdfBuilder;
+use horstoeko\zugferd\tests\traits\HandlesXmlTests;
+use horstoeko\zugferd\codelists\ZugferdPaymentMeans;
+use horstoeko\zugferd\exception\ZugferdUnknownMimetype;
+use horstoeko\zugferd\ZugferdDocumentPdfBuilderAbstract;
+use horstoeko\zugferd\exception\ZugferdFileNotFoundException;
 
 class PdfBuilderEn16931Test extends TestCase
 {
@@ -83,7 +87,11 @@ class PdfBuilderEn16931Test extends TestCase
         @unlink(self::$destPdfFilename);
     }
 
-    public function testBuildPdf(): void
+    /**
+     * Tests
+     */
+
+    public function testBuildFromSourcePdfFile(): void
     {
         $pdfBuilder = new ZugferdDocumentPdfBuilder(self::$document, self::$sourcePdfFilename);
         $pdfBuilder->generateDocument();
@@ -92,7 +100,7 @@ class PdfBuilderEn16931Test extends TestCase
         $this->assertTrue(file_exists(self::$destPdfFilename));
     }
 
-    public function testBuildPdfString(): void
+    public function testBuildFromSourcePdfString(): void
     {
         $pdfBuilder = new ZugferdDocumentPdfBuilder(self::$document, self::$sourcePdfFilename);
         $pdfBuilder->generateDocument();
@@ -103,6 +111,10 @@ class PdfBuilderEn16931Test extends TestCase
 
     public function testPdfMetaData(): void
     {
+        $pdfBuilder = new ZugferdDocumentPdfBuilder(self::$document, self::$sourcePdfFilename);
+        $pdfBuilder->generateDocument();
+        $pdfBuilder->saveDocument(self::$destPdfFilename);
+
         $pdfParser = new PdfParser();
         $pdfParsed = $pdfParser->parseFile(self::$destPdfFilename);
         $pdfDetails = $pdfParsed->getDetails();
@@ -114,120 +126,362 @@ class PdfBuilderEn16931Test extends TestCase
         $this->assertEquals("1", $pdfDetails["Pages"]);
     }
 
-    public function testReadPdf(): void
+    public function testFromExistingPdfFile(): void
+    {
+        $pdfBuilder = ZugferdDocumentPdfBuilder::fromPdfFile(self::$document, self::$sourcePdfFilename);
+        $pdfBuilder->generateDocument();
+        $pdfBuilder->downloadString(self::$destPdfFilename);
+
+        $this->assertIsString(self::$destPdfFilename);
+    }
+
+    public function testFromNotExistingPdfFile(): void
+    {
+        $this->expectException(ZugferdFileNotFoundException::class);
+
+        ZugferdDocumentPdfBuilder::fromPdfFile(self::$document, '/tmp/anonexisting.pdf');
+    }
+
+    public function testFromPdfString(): void
+    {
+        $pdfString = file_get_contents(self::$sourcePdfFilename);
+
+        $pdfBuilder = ZugferdDocumentPdfBuilder::fromPdfString(self::$document, $pdfString);
+        $pdfBuilder->generateDocument();
+        $pdfBuilder->downloadString(self::$destPdfFilename);
+
+        $this->assertIsString(self::$destPdfFilename);
+    }
+
+    public function testFromPdfStringWhichIsInvalid(): void
     {
-        $document = ZugferdDocumentPdfReader::readAndGuessFromFile(self::$destPdfFilename);
+        $this->expectException(PdfParserException::class);
+        $this->expectExceptionMessage('Unable to find PDF file header.');
+
+        $pdfString = 'this_is_not_a_pdf_string';
 
-        $this->assertNotNull($document);
-        $this->assertEquals(ZugferdProfiles::PROFILE_EN16931, $document->getProfileId());
-        $this->assertNotEquals(ZugferdProfiles::PROFILE_BASIC, $document->getProfileId());
-        $this->assertNotEquals(ZugferdProfiles::PROFILE_BASICWL, $document->getProfileId());
-        $this->assertNotEquals(ZugferdProfiles::PROFILE_EXTENDED, $document->getProfileId());
-        $this->assertNotEquals(ZugferdProfiles::PROFILE_XRECHNUNG, $document->getProfileId());
+        $pdfBuilder = ZugferdDocumentPdfBuilder::fromPdfString(self::$document, $pdfString);
+        $pdfBuilder->generateDocument();
+        $pdfBuilder->downloadString(self::$destPdfFilename);
     }
 
-    public function testGetXmlContent(): void
+    public function testSetAdditionalCreatorTool(): void
     {
-        $mockedObject = $this->getMockBuilder(ZugferdDocumentPdfBuilder::class)
-            ->setConstructorArgs([self::$document, self::$sourcePdfFilename])
-            ->onlyMethods(['getXmlContent'])
-            ->getMock();
+        $pdfBuilder = ZugferdDocumentPdfBuilder::fromPdfFile(self::$document, self::$sourcePdfFilename);
+        $pdfBuilder->setAdditionalCreatorTool('Dummy');
 
-        /**
-         * @var \PHPUnit\Framework\MockObject\MockObject $mockedObject
-         */
-        $mockedObject->expects($this->exactly(2))
-            ->method("getXmlContent")
-            ->willReturn(self::$document->getContent());
+        $toolName = sprintf('Factur-X PHP library v%s by HorstOeko', ZugferdPackageVersion::getInstalledVersion());
 
-        /**
-         * @var \horstoeko\zugferd\ZugferdDocumentPdfBuilder $mockedObject
-         */
-        $result = $mockedObject->generateDocument();
+        $this->assertStringStartsWith('Dummy / Factur-X PHP library', $pdfBuilder->getCreatorToolName());
+    }
 
-        $this->assertInstanceOf(ZugferdDocumentPdfBuilder::class, $result);
+    public function testSetAttachmentRelationshipTypeToUnknown(): void
+    {
+        $pdfBuilder = ZugferdDocumentPdfBuilder::fromPdfFile(self::$document, self::$sourcePdfFilename);
+        $pdfBuilder->setAttachmentRelationshipType('unknown');
+
+        $this->assertEquals(ZugferdDocumentPdfBuilder::AF_RELATIONSHIP_DATA, $pdfBuilder->getAttachmentRelationshipType());
     }
 
-    public function testGetXmlAttachmentFilename(): void
+    public function testSetAttachmentRelationshipTypeToData(): void
     {
-        $mockedObject = $this->getMockBuilder(ZugferdDocumentPdfBuilder::class)
-            ->setConstructorArgs([self::$document, self::$sourcePdfFilename])
-            ->onlyMethods(['getXmlAttachmentFilename'])
-            ->getMock();
+        $pdfBuilder = ZugferdDocumentPdfBuilder::fromPdfFile(self::$document, self::$sourcePdfFilename);
+        $pdfBuilder->setAttachmentRelationshipType('Data');
 
-        /**
-         * @var \PHPUnit\Framework\MockObject\MockObject $mockedObject
-         */
-        $mockedObject->expects($this->exactly(2))
-            ->method("getXmlAttachmentFilename")
-            ->willReturn(self::$document->getProfileDefinitionParameter('attachmentfilename'));
+        $this->assertEquals(ZugferdDocumentPdfBuilder::AF_RELATIONSHIP_DATA, $pdfBuilder->getAttachmentRelationshipType());
+    }
 
-        /**
-         * @var \horstoeko\zugferd\ZugferdDocumentPdfBuilder $mockedObject
-         */
-        $result = $mockedObject->generateDocument();
+    public function testSetAttachmentRelationshipTypeToAlternative(): void
+    {
+        $pdfBuilder = ZugferdDocumentPdfBuilder::fromPdfFile(self::$document, self::$sourcePdfFilename);
+        $pdfBuilder->setAttachmentRelationshipType('Alternative');
 
-        $this->assertInstanceOf(ZugferdDocumentPdfBuilder::class, $result);
+        $this->assertEquals(ZugferdDocumentPdfBuilder::AF_RELATIONSHIP_ALTERNATIVE, $pdfBuilder->getAttachmentRelationshipType());
     }
 
-    public function testGetXmlAttachmentXmpName(): void
+    public function testSetAttachmentRelationshipTypeToSource(): void
     {
-        $mockedObject = $this->getMockBuilder(ZugferdDocumentPdfBuilder::class)
-            ->setConstructorArgs([self::$document, self::$sourcePdfFilename])
-            ->onlyMethods(['getXmlAttachmentXmpName'])
-            ->getMock();
+        $pdfBuilder = ZugferdDocumentPdfBuilder::fromPdfFile(self::$document, self::$sourcePdfFilename);
+        $pdfBuilder->setAttachmentRelationshipType('Source');
 
-        /**
-         * @var \PHPUnit\Framework\MockObject\MockObject $mockedObject
-         */
-        $mockedObject->expects($this->exactly(1))
-            ->method("getXmlAttachmentXmpName")
-            ->willReturn(self::$document->getProfileDefinitionParameter("xmpname"));
+        $this->assertEquals(ZugferdDocumentPdfBuilder::AF_RELATIONSHIP_SOURCE, $pdfBuilder->getAttachmentRelationshipType());
+    }
 
-        /**
-         * @var \horstoeko\zugferd\ZugferdDocumentPdfBuilder $mockedObject
-         */
-        $result = $mockedObject->generateDocument();
+    public function testSetAttachmentRelationshipTypeToDataDirect(): void
+    {
+        $pdfBuilder = ZugferdDocumentPdfBuilder::fromPdfFile(self::$document, self::$sourcePdfFilename);
+        $pdfBuilder->setAttachmentRelationshipTypeToData();
 
-        $this->assertInstanceOf(ZugferdDocumentPdfBuilder::class, $result);
+        $this->assertEquals(ZugferdDocumentPdfBuilder::AF_RELATIONSHIP_DATA, $pdfBuilder->getAttachmentRelationshipType());
     }
 
-    public function testFromPdfFile(): void
+    public function testSetAttachmentRelationshipTypeToAlternativeDirect(): void
     {
         $pdfBuilder = ZugferdDocumentPdfBuilder::fromPdfFile(self::$document, self::$sourcePdfFilename);
-        $pdfBuilder->generateDocument();
-        $pdfBuilder->downloadString(self::$destPdfFilename);
+        $pdfBuilder->setAttachmentRelationshipTypeToAlternative();
 
-        $this->assertIsString(self::$destPdfFilename);
+        $this->assertEquals(ZugferdDocumentPdfBuilder::AF_RELATIONSHIP_ALTERNATIVE, $pdfBuilder->getAttachmentRelationshipType());
     }
 
-    public function testFromNotExistingPdfFile(): void
+    public function testSetAttachmentRelationshipTypeToSourceDirect(): void
     {
+        $pdfBuilder = ZugferdDocumentPdfBuilder::fromPdfFile(self::$document, self::$sourcePdfFilename);
+        $pdfBuilder->setAttachmentRelationshipTypeToSource();
+
+        $this->assertEquals(ZugferdDocumentPdfBuilder::AF_RELATIONSHIP_SOURCE, $pdfBuilder->getAttachmentRelationshipType());
+    }
+
+    public function testAttachAdditionalFileFileDoesNotExist(): void
+    {
+        $filename = dirname(__FILE__) . '/unknown.txt';
+
         $this->expectException(ZugferdFileNotFoundException::class);
+        $this->expectExceptionMessage(sprintf("The file %s was not found", $filename));
 
-        $pdfBuilder = ZugferdDocumentPdfBuilder::fromPdfFile(self::$document, '/tmp/anonexisting.pdf');
+        $pdfBuilder = ZugferdDocumentPdfBuilder::fromPdfFile(self::$document, self::$sourcePdfFilename);
+        $pdfBuilder->attachAdditionalFileByRealFile($filename);
     }
 
-    public function testFromPdfString(): void
+    public function testAttachAdditionalFileFileIsEmpty(): void
     {
-        $pdfString = file_get_contents(self::$sourcePdfFilename);
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage("You must specify a filename for the content to attach");
 
-        $pdfBuilder = ZugferdDocumentPdfBuilder::fromPdfString(self::$document, $pdfString);
+        $pdfBuilder = ZugferdDocumentPdfBuilder::fromPdfFile(self::$document, self::$sourcePdfFilename);
+        $pdfBuilder->attachAdditionalFileByRealFile("");
+    }
+
+    public function testAttachAdditionalFileMimetypeUnknown(): void
+    {
+        $filename = dirname(__FILE__) . "/../assets/dummy_attachment_1.dummy";
+
+        $this->expectException(ZugferdUnknownMimetype::class);
+        $this->expectExceptionMessage("No mimetype found");
+
+        $pdfBuilder = ZugferdDocumentPdfBuilder::fromPdfFile(self::$document, self::$sourcePdfFilename);
+        $pdfBuilder->attachAdditionalFileByRealFile($filename);
+    }
+
+    public function testAttachAdditionalFileInvalidRelationShip(): void
+    {
+        $filename = dirname(__FILE__) . "/../assets/txt_addattachment_1.txt";
+
+        $pdfBuilder = ZugferdDocumentPdfBuilder::fromPdfFile(self::$document, self::$sourcePdfFilename);
+        $pdfBuilder->attachAdditionalFileByRealFile($filename, "", "Dummy");
+
+        $property = $this->getPrivatePropertyFromClassname(ZugferdDocumentPdfBuilderAbstract::class, "additionalFilesToAttach");
+
+        $this->assertIsArray($property->getValue($pdfBuilder));
+        $this->assertIsArray($property->getValue($pdfBuilder)[0]);
+        $this->assertEquals(ZugferdDocumentPdfBuilder::AF_RELATIONSHIP_SUPPLEMENT, $property->getValue($pdfBuilder)[0][3]);
+    }
+
+    public function testAttachAdditionalFileValidRelationShip(): void
+    {
+        $filename = dirname(__FILE__) . "/../assets/txt_addattachment_1.txt";
+
+        $pdfBuilder = ZugferdDocumentPdfBuilder::fromPdfFile(self::$document, self::$sourcePdfFilename);
+        $pdfBuilder->attachAdditionalFileByRealFile($filename, "", "Alternative");
+
+        $property = $this->getPrivatePropertyFromClassname(ZugferdDocumentPdfBuilderAbstract::class, "additionalFilesToAttach");
+
+        $this->assertIsArray($property->getValue($pdfBuilder));
+        $this->assertIsArray($property->getValue($pdfBuilder)[0]);
+        $this->assertEquals(ZugferdDocumentPdfBuilder::AF_RELATIONSHIP_ALTERNATIVE, $property->getValue($pdfBuilder)[0][3]);
+    }
+
+    public function testAttachAdditionalFileFinalResult(): void
+    {
+        $filename = dirname(__FILE__) . "/../assets/txt_addattachment_1.txt";
+
+        $pdfBuilder = ZugferdDocumentPdfBuilder::fromPdfFile(self::$document, self::$sourcePdfFilename);
+        $pdfBuilder->attachAdditionalFileByRealFile($filename, "", "Alternative");
+
+        $property = $this->getPrivatePropertyFromClassname(ZugferdDocumentPdfBuilderAbstract::class, "additionalFilesToAttach");
+
+        $this->assertIsArray($property->getValue($pdfBuilder));
+        $this->assertIsArray($property->getValue($pdfBuilder)[0]);
+        $this->assertInstanceOf(StreamReader::class, $property->getValue($pdfBuilder)[0][0]);
+        $this->assertEquals("txt_addattachment_1.txt", $property->getValue($pdfBuilder)[0][1]);
+        $this->assertEquals("txt_addattachment_1.txt", $property->getValue($pdfBuilder)[0][2]);
+        $this->assertEquals(ZugferdDocumentPdfBuilder::AF_RELATIONSHIP_ALTERNATIVE, $property->getValue($pdfBuilder)[0][3]);
+    }
+
+    public function testAdditionalFilesAreEmbedded(): void
+    {
+        $pdfBuilder = ZugferdDocumentPdfBuilder::fromPdfFile(self::$document, self::$sourcePdfFilename);
+        $pdfBuilder->attachAdditionalFileByRealFile(dirname(__FILE__) . "/../assets/txt_addattachment_1.txt");
         $pdfBuilder->generateDocument();
-        $pdfBuilder->downloadString(self::$destPdfFilename);
+        $pdfBuilder->saveDocument(self::$destPdfFilename);
 
-        $this->assertIsString(self::$destPdfFilename);
+        $pdfParser = new PdfParser();
+        $pdfParsed = $pdfParser->parseFile(self::$destPdfFilename);
+        $pdfFilespecs = $pdfParsed->getObjectsByType('Filespec');
+
+        $this->assertIsArray($pdfFilespecs);
+        $this->assertEquals(2, count($pdfFilespecs));
+        $this->assertArrayHasKey("8_0", $pdfFilespecs);
+        $this->assertArrayHasKey("10_0", $pdfFilespecs);
+
+        $pdfFilespec = $pdfFilespecs["8_0"];
+        $pdfFilespecDetails = $pdfFilespec->getDetails();
+
+        $this->assertIsArray($pdfFilespecDetails);
+        $this->assertArrayHasKey("F", $pdfFilespecDetails);
+        $this->assertArrayHasKey("Type", $pdfFilespecDetails);
+        $this->assertArrayHasKey("UF", $pdfFilespecDetails);
+        $this->assertArrayHasKey("AFRelationship", $pdfFilespecDetails);
+        $this->assertArrayHasKey("Desc", $pdfFilespecDetails);
+        $this->assertArrayHasKey("EF", $pdfFilespecDetails);
+        $this->assertEquals("factur-x.xml", $pdfFilespecDetails["F"]);
+        $this->assertEquals("Filespec", $pdfFilespecDetails["Type"]);
+        $this->assertEquals("factur-x.xml", $pdfFilespecDetails["UF"]);
+        $this->assertEquals(ZugferdDocumentPdfBuilder::AF_RELATIONSHIP_DATA, $pdfFilespecDetails["AFRelationship"]);
+        $this->assertEquals("Factur-X Invoice", $pdfFilespecDetails["Desc"]);
+
+        $pdfFilespec = $pdfFilespecs["10_0"];
+        $pdfFilespecDetails = $pdfFilespec->getDetails();
+
+        $this->assertIsArray($pdfFilespecDetails);
+        $this->assertArrayHasKey("F", $pdfFilespecDetails);
+        $this->assertArrayHasKey("Type", $pdfFilespecDetails);
+        $this->assertArrayHasKey("UF", $pdfFilespecDetails);
+        $this->assertArrayHasKey("AFRelationship", $pdfFilespecDetails);
+        $this->assertArrayHasKey("Desc", $pdfFilespecDetails);
+        $this->assertArrayHasKey("EF", $pdfFilespecDetails);
+        $this->assertEquals("txt_addattachment_1.txt", $pdfFilespecDetails["F"]);
+        $this->assertEquals("Filespec", $pdfFilespecDetails["Type"]);
+        $this->assertEquals("txt_addattachment_1.txt", $pdfFilespecDetails["UF"]);
+        $this->assertEquals(ZugferdDocumentPdfBuilder::AF_RELATIONSHIP_SUPPLEMENT, $pdfFilespecDetails["AFRelationship"]);
+        $this->assertEquals("txt_addattachment_1.txt", $pdfFilespecDetails["Desc"]);
+
+        $pdfFilespecDetailsEF = $pdfFilespecDetails["EF"];
+        $this->assertIsArray($pdfFilespecDetailsEF);
+        $this->assertArrayHasKey("F", $pdfFilespecDetailsEF);
+        $this->assertArrayHasKey("UF", $pdfFilespecDetailsEF);
+
+        $pdfFilespecDetailsEF_F = $pdfFilespecDetailsEF["F"];
+        $this->assertIsArray($pdfFilespecDetailsEF_F);
+        $this->assertArrayHasKey("Filter", $pdfFilespecDetailsEF_F);
+        $this->assertArrayHasKey("Subtype", $pdfFilespecDetailsEF_F);
+        $this->assertArrayHasKey("Type", $pdfFilespecDetailsEF_F);
+        $this->assertArrayHasKey("Length", $pdfFilespecDetailsEF_F);
+        $this->assertEquals("FlateDecode", $pdfFilespecDetailsEF_F["Filter"]);
+        $this->assertEquals("text/plain", $pdfFilespecDetailsEF_F["Subtype"]);
+        $this->assertEquals("EmbeddedFile", $pdfFilespecDetailsEF_F["Type"]);
+        $this->assertEquals(195, $pdfFilespecDetailsEF_F["Length"]);
+
+        $pdfFilespecDetailsEF_UF = $pdfFilespecDetailsEF["UF"];
+        $this->assertIsArray($pdfFilespecDetailsEF_UF);
+        $this->assertArrayHasKey("Filter", $pdfFilespecDetailsEF_UF);
+        $this->assertArrayHasKey("Subtype", $pdfFilespecDetailsEF_UF);
+        $this->assertArrayHasKey("Type", $pdfFilespecDetailsEF_UF);
+        $this->assertArrayHasKey("Length", $pdfFilespecDetailsEF_UF);
+        $this->assertEquals("FlateDecode", $pdfFilespecDetailsEF_UF["Filter"]);
+        $this->assertEquals("text/plain", $pdfFilespecDetailsEF_UF["Subtype"]);
+        $this->assertEquals("EmbeddedFile", $pdfFilespecDetailsEF_UF["Type"]);
+        $this->assertEquals(195, $pdfFilespecDetailsEF_UF["Length"]);
     }
 
-    public function testFromPdfStringWhichIsInvalid(): void
+    public function testAttachAdditionalFileByContentEmptyContent(): void
     {
-        $this->expectException(PdfParserException::class);
-        $this->expectExceptionMessage('Unable to find PDF file header.');
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage("You must specify a content to attach");
 
-        $pdfString = 'this_is_not_a_pdf_string';
+        $pdfBuilder = ZugferdDocumentPdfBuilder::fromPdfFile(self::$document, self::$sourcePdfFilename);
+        $pdfBuilder->attachAdditionalFileByContent("", "", "", "");
+    }
 
-        $pdfBuilder = ZugferdDocumentPdfBuilder::fromPdfString(self::$document, $pdfString);
+    public function testAttachAdditionalFileByContentEmptyFilename(): void
+    {
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage("You must specify a filename for the content to attach");
+
+        $filename = dirname(__FILE__) . "/../assets/txt_addattachment_1.txt";
+
+        $content = file_get_contents($filename);
+
+        $pdfBuilder = ZugferdDocumentPdfBuilder::fromPdfFile(self::$document, self::$sourcePdfFilename);
+        $pdfBuilder->attachAdditionalFileByContent($content, "", "", "");
+    }
+
+    public function testAttachAdditionalFileByContentAllValid(): void
+    {
+        $filename = dirname(__FILE__) . "/../assets/txt_addattachment_1.txt";
+
+        $content = file_get_contents($filename);
+
+        $pdfBuilder = ZugferdDocumentPdfBuilder::fromPdfFile(self::$document, self::$sourcePdfFilename);
+        $pdfBuilder->attachAdditionalFileByContent($content, "file.txt", "A file attachment");
         $pdfBuilder->generateDocument();
-        $pdfBuilder->downloadString(self::$destPdfFilename);
+        $pdfBuilder->saveDocument(self::$destPdfFilename);
+
+        $pdfParser = new PdfParser();
+        $pdfParsed = $pdfParser->parseFile(self::$destPdfFilename);
+        $pdfFilespecs = $pdfParsed->getObjectsByType('Filespec');
+
+        $this->assertIsArray($pdfFilespecs);
+        $this->assertEquals(2, count($pdfFilespecs));
+        $this->assertArrayHasKey("8_0", $pdfFilespecs);
+        $this->assertArrayHasKey("10_0", $pdfFilespecs);
+
+        $pdfFilespec = $pdfFilespecs["8_0"];
+        $pdfFilespecDetails = $pdfFilespec->getDetails();
+
+        $this->assertIsArray($pdfFilespecDetails);
+        $this->assertArrayHasKey("F", $pdfFilespecDetails);
+        $this->assertArrayHasKey("Type", $pdfFilespecDetails);
+        $this->assertArrayHasKey("UF", $pdfFilespecDetails);
+        $this->assertArrayHasKey("AFRelationship", $pdfFilespecDetails);
+        $this->assertArrayHasKey("Desc", $pdfFilespecDetails);
+        $this->assertArrayHasKey("EF", $pdfFilespecDetails);
+        $this->assertEquals("factur-x.xml", $pdfFilespecDetails["F"]);
+        $this->assertEquals("Filespec", $pdfFilespecDetails["Type"]);
+        $this->assertEquals("factur-x.xml", $pdfFilespecDetails["UF"]);
+        $this->assertEquals(ZugferdDocumentPdfBuilder::AF_RELATIONSHIP_DATA, $pdfFilespecDetails["AFRelationship"]);
+        $this->assertEquals("Factur-X Invoice", $pdfFilespecDetails["Desc"]);
+
+        $pdfFilespec = $pdfFilespecs["10_0"];
+        $pdfFilespecDetails = $pdfFilespec->getDetails();
+
+        $this->assertIsArray($pdfFilespecDetails);
+        $this->assertArrayHasKey("F", $pdfFilespecDetails);
+        $this->assertArrayHasKey("Type", $pdfFilespecDetails);
+        $this->assertArrayHasKey("UF", $pdfFilespecDetails);
+        $this->assertArrayHasKey("AFRelationship", $pdfFilespecDetails);
+        $this->assertArrayHasKey("Desc", $pdfFilespecDetails);
+        $this->assertArrayHasKey("EF", $pdfFilespecDetails);
+        $this->assertEquals("file.txt", $pdfFilespecDetails["F"]);
+        $this->assertEquals("Filespec", $pdfFilespecDetails["Type"]);
+        $this->assertEquals("file.txt", $pdfFilespecDetails["UF"]);
+        $this->assertEquals(ZugferdDocumentPdfBuilder::AF_RELATIONSHIP_SUPPLEMENT, $pdfFilespecDetails["AFRelationship"]);
+        $this->assertEquals("A file attachment", $pdfFilespecDetails["Desc"]);
+
+        $pdfFilespecDetailsEF = $pdfFilespecDetails["EF"];
+        $this->assertIsArray($pdfFilespecDetailsEF);
+        $this->assertArrayHasKey("F", $pdfFilespecDetailsEF);
+        $this->assertArrayHasKey("UF", $pdfFilespecDetailsEF);
+
+        $pdfFilespecDetailsEF_F = $pdfFilespecDetailsEF["F"];
+        $this->assertIsArray($pdfFilespecDetailsEF_F);
+        $this->assertArrayHasKey("Filter", $pdfFilespecDetailsEF_F);
+        $this->assertArrayHasKey("Subtype", $pdfFilespecDetailsEF_F);
+        $this->assertArrayHasKey("Type", $pdfFilespecDetailsEF_F);
+        $this->assertArrayHasKey("Length", $pdfFilespecDetailsEF_F);
+        $this->assertEquals("FlateDecode", $pdfFilespecDetailsEF_F["Filter"]);
+        $this->assertEquals("text/plain", $pdfFilespecDetailsEF_F["Subtype"]);
+        $this->assertEquals("EmbeddedFile", $pdfFilespecDetailsEF_F["Type"]);
+        $this->assertEquals(195, $pdfFilespecDetailsEF_F["Length"]);
+
+        $pdfFilespecDetailsEF_UF = $pdfFilespecDetailsEF["UF"];
+        $this->assertIsArray($pdfFilespecDetailsEF_UF);
+        $this->assertArrayHasKey("Filter", $pdfFilespecDetailsEF_UF);
+        $this->assertArrayHasKey("Subtype", $pdfFilespecDetailsEF_UF);
+        $this->assertArrayHasKey("Type", $pdfFilespecDetailsEF_UF);
+        $this->assertArrayHasKey("Length", $pdfFilespecDetailsEF_UF);
+        $this->assertEquals("FlateDecode", $pdfFilespecDetailsEF_UF["Filter"]);
+        $this->assertEquals("text/plain", $pdfFilespecDetailsEF_UF["Subtype"]);
+        $this->assertEquals("EmbeddedFile", $pdfFilespecDetailsEF_UF["Type"]);
+        $this->assertEquals(195, $pdfFilespecDetailsEF_UF["Length"]);
     }
 }