Repository: gotenberg/gotenberg

Issue:
Set additional XMP RDF to the document
Hey, is this possible via Gotenberg?
https://mpdf.github.io/what-else-can-i-do/pdf-a3-xmp-rdf.html

We would like to add invoicing data (ZUGFeRD) to our pdf files and I prefer not to use an additional library.

We are currently using Laravel + Twig + Chromium to create the invoice.

Thanks in advance

Discussion:
@gulien: Hello @Cluster2a, 

Did you try https://gotenberg.dev/docs/routes#metadata-chromium?@Cluster2a: @gulien , I think the way to realize inline XML is a file embedding, which seems like it has nothing to do with the metadata. Sorry for the misleading title.

https://invoice-portal.de/wp-content/uploads/2021/12/ZUGFeRD-Example.pdf
Here you have an example. If you check the source code, you will find the XML:
![image](https://github.com/gotenberg/gotenberg/assets/84905165/893a9b9e-c324-46f1-a664-54cb49e916d3)
It also works as a stream inline.

The XML within this document looks like this:
![image](https://github.com/gotenberg/gotenberg/assets/84905165/aed8a85b-48da-46fe-abd0-efc52897f33c)

As this will be required soon in Germany (and the EU?), the only chance right now is to edit the created pdf with another tool. Would be cool, if we could pass this XML to gotenberg and embed it directly.@gulien: There is no native support via LibreOffice for the `factur-x` standard. I found an extension that is allegedly capable to generate compliant PDF, but I have not idea how to trigger it programmatically 🤔@Cluster2a: We Are currently using chromium + html for the rendering.

Do you see any chance to make that happen?

Maybe after rendering, we could pass the pdf to pdfcou (https://pdfcpu.io/attach/attach_add.html) and adding the XML as an a attachment?

But currently, there is no API for this kind of stuff in the client.


Maybe helpful: https://github.com/pdfcpu/pdfcpu/issues/239
@gulien: `pdfcpu` may be helpful, good catch! Could you try it and tell me if it works with your use case?@Cluster2a: @gulien, the following PDF is a valid one (got this from https://www.zugferd-community.net/de/zf_fx_invoiceportal/invoice_creation.
[ZFdemo_2p1_en16931_66434a8b1e5b3.pdf](https://github.com/gotenberg/gotenberg/files/15307752/ZFdemo_2p1_en16931_66434a8b1e5b3.pdf)

The validation confirms that this is valid:
![image](https://github.com/gotenberg/gotenberg/assets/84905165/55a63c31-8b7c-4380-8072-513b3295cea2)

Adding the x-factor.xml via `pdfcpu` works like charm.
But on validation, I am getting a few errors:
![image](https://github.com/gotenberg/gotenberg/assets/84905165/b6d38580-2fa2-4235-a81f-8274699f9a08)

Here is the detailed report:
```xml
<!-- ?xml version="1.0" encoding="UTF-8"? -->
<validation filename="IN-24-1000.pdf" datetime="2024-05-14 13:29:50">
	<pdf>
		<report>
			<buildinformation>
				<releasedetails id="core" version="1.22.2" builddate="2022-09-14T13:46:00+02:00"/>
				<releasedetails id="validation-model" version="1.22.2" builddate="2022-09-14T13:47:00+02:00"/>
			</buildinformation>
			<jobs>
				<job>
					<item size="38939">
						<name>/var/www/zugferd_community/zugferd_checker/uploads/IN-24-1000.pdf</name>
					</item>
					<validationreport profilename="PDF/A-3B validation profile" statement="PDF file is not compliant with Validation Profile requirements." iscompliant="false">
						<details passedrules="121" failedrules="3" passedchecks="5485" failedchecks="8">
							<rule specification="ISO 19005-3:2012" clause="6.8" testnumber="3" status="failed" passedchecks="0" failedchecks="2">
								<description>In order to enable identification of the relationship between the file specification dictionary and the content that is referring to it, a new (required) key has been defined and its presence (in the dictionary) is required.</description>
								<object>CosFileSpecification</object>
								<test>AFRelationship != null</test>
								<check status="failed">
									<context>root/EmbeddedFiles[0]</context>
									<errormessage>The file specification dictionary for an embedded file does not contain the AFRelationship key</errormessage>
								</check>
								<check status="failed">
									<context>root/indirectObjects[107](115 0)/directObject[0]</context>
									<errormessage>The file specification dictionary for an embedded file does not contain the AFRelationship key</errormessage>
								</check>
							</rule>
							<rule specification="ISO 19005-3:2012" clause="6.8" testnumber="4" status="failed" passedchecks="0" failedchecks="2">
								<description>The additional information provided for associated files as well as the usage requirements for associated files indicate the relationship between the embedded file and the PDF document or the part of the PDF document with which it is associated.</description>
								<object>CosFileSpecification</object>
								<test>isAssociatedFile == true</test>
								<check status="failed">
									<context>root/EmbeddedFiles[0]</context>
									<errormessage>The file specification dictionary for an embedded file is not associated with the PDF document or any of its parts</errormessage>
								</check>
								<check status="failed">
									<context>root/indirectObjects[107](115 0)/directObject[0]</context>
									<errormessage>The file specification dictionary for an embedded file is not associated with the PDF document or any of its parts</errormessage>
								</check>
							</rule>
							<rule specification="ISO 19005-3:2012" clause="6.8" testnumber="1" status="failed" passedchecks="0" failedchecks="4">
								<description>The MIME type of an embedded file, or a subset of a file, shall be specified using the Subtype key of the file specification dictionary. If the MIME type is not known, the "application/octet-stream" shall be used.</description>
								<object>EmbeddedFile</object>
								<test>Subtype != null && /^[-\w+\.]+\/[-\w+\.]+$/.test(Subtype)</test>
								<check status="failed">
									<context>root/EmbeddedFiles[0]/EF[0]</context>
									<errormessage>MIME type null of an embedded file is missing or invalid</errormessage>
								</check>
								<check status="failed">
									<context>root/EmbeddedFiles[0]/EF[1]</context>
									<errormessage>MIME type null of an embedded file is missing or invalid</errormessage>
								</check>
								<check status="failed">
									<context>root/indirectObjects[107](115 0)/directObject[0]/EF[0]</context>
									<errormessage>MIME type null of an embedded file is missing or invalid</errormessage>
								</check>
								<check status="failed">
									<context>root/indirectObjects[107](115 0)/directObject[0]/EF[1]</context>
									<errormessage>MIME type null of an embedded file is missing or invalid</errormessage>
								</check>
							</rule>
						</details>
					</validationreport>
					<duration start="1715686190761" finish="1715686192013">00:00:01.252</duration>
				</job>
			</jobs>
			<batchsummary totaljobs="1" failedtoparse="0" encrypted="0" outofmemory="0" veraexceptions="0">
				<validationreports compliant="0" noncompliant="1" failedjobs="0">1</validationreports>
				<featurereports failedjobs="0">0</featurereports>
				<repairreports failedjobs="0">0</repairreports>
				<duration start="1715686190624" finish="1715686192048">00:00:01.424</duration>
			</batchsummary>
		</report>
		<info>
			<signature>unknown</signature>
			<duration unit="ms">1842</duration>
		</info>
		<messages>
			<error type="11">XMP Metadata: ConformanceLevel not found</error>
			<error type="12">XMP Metadata: ConformanceLevel contains invalid value</error>
			<error type="13">XMP Metadata: DocumentType not found</error>
			<error type="14">XMP Metadata: DocumentType invalid</error>
			<error type="21">XMP Metadata: DocumentFileName not found</error>
			<error type="19">XMP Metadata: DocumentFileName contains invalid value</error>
			<error type="15">XMP Metadata: Version not found</error>
			<error type="16">XMP Metadata: Version contains invalid value</error>
		</messages>
		<summary status="invalid"/>
	</pdf>
	<xml>
		<info>
			<version>2</version>
			<profile>urn:cen.eu:en16931:2017</profile>
			<validator version="2.10.0"/>
			<rules>
				<fired>22</fired>
				<failed>17</failed>
			</rules>
			<duration unit="ms">6470</duration>
		</info>
		<messages>
			<notice type="27" location="/rsm:CrossIndustryInvoice/rsm:ExchangedDocumentContext[1]" criterion="ram:BusinessProcessSpecifiedDocumentContextParameter/ram:ID">Business process MUST be provided. [ID PEPPOL-EN16931-R001] from /xslt/XR_30/XRechnung-CII-validation.xslt)</notice>
			<notice type="27" location="/rsm:CrossIndustryInvoice/rsm:SupplyChainTradeTransaction[1]/ram:ApplicableHeaderTradeAgreement[1]/ram:SellerTradeParty[1]" criterion="ram:URIUniversalCommunication/ram:URIID">Seller electronic address MUST be provided [ID PEPPOL-EN16931-R020] from /xslt/XR_30/XRechnung-CII-validation.xslt)</notice>
			<notice type="27" location="/rsm:CrossIndustryInvoice/rsm:SupplyChainTradeTransaction[1]/ram:ApplicableHeaderTradeAgreement[1]/ram:BuyerTradeParty[1]" criterion="ram:URIUniversalCommunication/ram:URIID">Buyer electronic address MUST be provided [ID PEPPOL-EN16931-R010] from /xslt/XR_30/XRechnung-CII-validation.xslt)</notice>
			<notice type="27" location="/rsm:CrossIndustryInvoice/rsm:ExchangedDocument[1]/ram:IncludedNote[1]/ram:Content[1]" criterion="false()">Document MUST not contain empty elements. [ID PEPPOL-EN16931-R008] from /xslt/XR_30/XRechnung-CII-validation.xslt)</notice>
			<notice type="27" location="/rsm:CrossIndustryInvoice/rsm:SupplyChainTradeTransaction[1]/ram:ApplicableHeaderTradeAgreement[1]/ram:SellerTradeParty[1]/ram:DefinedTradeContact[1]/ram:EmailURIUniversalCommunication[1]/ram:URIID[1]" criterion="false()">Document MUST not contain empty elements. [ID PEPPOL-EN16931-R008] from /xslt/XR_30/XRechnung-CII-validation.xslt)</notice>
			<notice type="27" location="/rsm:CrossIndustryInvoice/rsm:SupplyChainTradeTransaction[1]/ram:ApplicableHeaderTradeAgreement[1]/ram:BuyerTradeParty[1]/ram:DefinedTradeContact[1]/ram:PersonName[1]" criterion="false()">Document MUST not contain empty elements. [ID PEPPOL-EN16931-R008] from /xslt/XR_30/XRechnung-CII-validation.xslt)</notice>
			<notice type="27" location="/rsm:CrossIndustryInvoice/rsm:SupplyChainTradeTransaction[1]/ram:ApplicableHeaderTradeAgreement[1]/ram:BuyerTradeParty[1]/ram:DefinedTradeContact[1]/ram:EmailURIUniversalCommunication[1]/ram:URIID[1]" criterion="false()">Document MUST not contain empty elements. [ID PEPPOL-EN16931-R008] from /xslt/XR_30/XRechnung-CII-validation.xslt)</notice>
			<notice type="27" location="/rsm:CrossIndustryInvoice/rsm:SupplyChainTradeTransaction[1]/ram:ApplicableHeaderTradeAgreement[1]/ram:BuyerTradeParty[1]/ram:PostalTradeAddress[1]/ram:LineOne[1]" criterion="false()">Document MUST not contain empty elements. [ID PEPPOL-EN16931-R008] from /xslt/XR_30/XRechnung-CII-validation.xslt)</notice>
			<notice type="27" location="/rsm:CrossIndustryInvoice/rsm:SupplyChainTradeTransaction[1]/ram:ApplicableHeaderTradeSettlement[1]/ram:SpecifiedTradeSettlementPaymentMeans[1]/ram:PayeePartyCreditorFinancialAccount[1]/ram:IBANID[1]" criterion="false()">Document MUST not contain empty elements. [ID PEPPOL-EN16931-R008] from /xslt/XR_30/XRechnung-CII-validation.xslt)</notice>
			<notice type="27" location="/rsm:CrossIndustryInvoice/rsm:SupplyChainTradeTransaction[1]/ram:IncludedSupplyChainTradeLineItem[1]/ram:SpecifiedLineTradeAgreement[1]/ram:GrossPriceProductTradePrice[1]" criterion="not(ram:ChargeAmount) or xs:decimal(../ram:NetPriceProductTradePrice/ram:ChargeAmount) = xs:decimal(ram:ChargeAmount) - xs:decimal(ram:AppliedTradeAllowanceCharge/ram:ActualAmount)">Item net price MUST equal (Gross price - Allowance amount) when gross price is provided. [ID PEPPOL-EN16931-R046] from /xslt/XR_30/XRechnung-CII-validation.xslt)</notice>
			<notice type="27" location="/rsm:CrossIndustryInvoice" criterion="rsm:SupplyChainTradeTransaction/ram:ApplicableHeaderTradeAgreement/ram:BuyerReference[boolean(normalize-space(.))]">[BR-DE-15] Das Element "Buyer reference" (BT-10) muss ?bermittelt werden. [ID BR-DE-15] from /xslt/XR_30/XRechnung-CII-validation.xslt)</notice>
			<notice type="27" location="/rsm:CrossIndustryInvoice/rsm:ExchangedDocumentContext[1]" criterion="ram:GuidelineSpecifiedDocumentContextParameter/ram:ID = $XR-CIUS-ID or ram:GuidelineSpecifiedDocumentContextParameter/ram:ID = $XR-EXTENSION-ID">[BR-DE-21] Das Element "Specification identifier" (BT-24) soll syntaktisch der Kennung des Standards XRechnung entsprechen. [ID BR-DE-21] from /xslt/XR_30/XRechnung-CII-validation.xslt)</notice>
			<notice type="27" location="/rsm:CrossIndustryInvoice/rsm:SupplyChainTradeTransaction[1]/ram:ApplicableHeaderTradeAgreement[1]/ram:SellerTradeParty[1]/ram:DefinedTradeContact[1]" criterion="ram:TelephoneUniversalCommunication/ram:CompleteNumber[boolean(normalize-space(.))]">[BR-DE-6] Das Element "Seller contact telephone number" (BT-42) muss ?bermittelt werden. [ID BR-DE-6] from /xslt/XR_30/XRechnung-CII-validation.xslt)</notice>
			<notice type="27" location="/rsm:CrossIndustryInvoice/rsm:SupplyChainTradeTransaction[1]/ram:ApplicableHeaderTradeAgreement[1]/ram:SellerTradeParty[1]/ram:DefinedTradeContact[1]" criterion="ram:EmailURIUniversalCommunication/ram:URIID[boolean(normalize-space(.))]">[BR-DE-7] Das Element "Seller contact email address" (BT-43) muss ?bermittelt werden. [ID BR-DE-7] from /xslt/XR_30/XRechnung-CII-validation.xslt)</notice>
			<notice type="27" location="/rsm:CrossIndustryInvoice/rsm:SupplyChainTradeTransaction[1]/ram:ApplicableHeaderTradeAgreement[1]/ram:SellerTradeParty[1]/ram:DefinedTradeContact[1]" criterion="matches(normalize-space(ram:TelephoneUniversalCommunication/ram:CompleteNumber), $XR-TELEPHONE-REGEX)">[BR-DE-27] In BT-42 sollen mindestens drei Ziffern enthalten sein. [ID BR-DE-27] from /xslt/XR_30/XRechnung-CII-validation.xslt)</notice>
			<notice type="27" location="/rsm:CrossIndustryInvoice/rsm:SupplyChainTradeTransaction[1]/ram:ApplicableHeaderTradeAgreement[1]/ram:SellerTradeParty[1]/ram:DefinedTradeContact[1]" criterion="matches(normalize-space(ram:EmailURIUniversalCommunication/ram:URIID), $XR-EMAIL-REGEX)">[BR-DE-28] In BT-43 soll genau ein @-Zeichen enthalten sein, welches nicht von einem Leerzeichen, einem Punkt, aber mindestens zwei Zeichen auf beiden Seiten flankiert werden soll. Ein Punkt sollte nicht am Anfang oder am Ende stehen. [ID BR-DE-28] from /xslt/XR_30/XRechnung-CII-validation.xslt)</notice>
			<notice type="27" location="/rsm:CrossIndustryInvoice/rsm:SupplyChainTradeTransaction[1]/ram:ApplicableHeaderTradeSettlement[1]/ram:SpecifiedTradeSettlementPaymentMeans[1]" criterion="not(ram:TypeCode = '58') or matches(normalize-space(replace(ram:PayeePartyCreditorFinancialAccount/ram:IBANID, '([ \n\r\t\s])', '')), '^[A-Z]2[0-9]2[a-zA-Z0-9]0 30$') and xs:integer(string-join(for $cp in string-to-codepoints(concat(substring(normalize-space(replace(ram:PayeePartyCreditorFinancialAccount/ram:IBANID, '([ \n\r\t\s])', '')),5),upper-case(substring(normalize-space(replace(ram:PayeePartyCreditorFinancialAccount/ram:IBANID, '([ \n\r\t\s])', '')),1,2)),substring(normalize-space(replace(ram:PayeePartyCreditorFinancialAccount/ram:IBANID, '([ \n\r\t\s])', '')),3,2))) return (if($cp > 64) then string($cp - 55) else string($cp - 48)),'')) mod 97 = 1">[BR-DE-19] "Payment account identifier" (BT-84) soll eine korrekte IBAN enthalten, wenn in "Payment means type code" (BT-81) mit dem Code 58 SEPA als Zahlungsmittel gefordert wird. [ID BR-DE-19] from /xslt/XR_30/XRechnung-CII-validation.xslt)</notice>
		</messages>
		<summary status="valid"/>
	</xml>
	<messages/>
	<summary status="invalid"/>
</validation>
```

I probably made the mistake, that I created a PDF/A-3b-PDF, so adding an attachment makes it invalid.
Unfortunately I am not that deep into PDFs.@gulien: Maybe the solution is to add the XML before converting the PDF to PDF/A?@Cluster2a: I tried that, but the result is the same:
![image](https://github.com/gotenberg/gotenberg/assets/84905165/7a5d4145-f725-452f-b26b-fe08d6a2de52)

I used https://www.pdfforge.org/online/de/pdf-in-pdfa for conversion.

I am not sure how to add thos tags that are missing.

edit: I jsut saw that there are 0 failed rules add 0 failed checks... wondering why this is still invalid.
Maybe of an unknown signature?

eidt: I double checked the working PDF and my PDF (pdfcpu + converrted to PDF/A):
![image](https://github.com/gotenberg/gotenberg/assets/84905165/c0270bb0-28d0-4783-8f06-71508f87a5f6)

![image](https://github.com/gotenberg/gotenberg/assets/84905165/57dcf60f-3333-44b2-b8ee-08c4ae2e5845)


As you can see, the first image contains the Attributes that are shown as the missing ones.
Is there a way to get this into the pdf?@gulien: No idea 😅 

Solution patch:
diff --git a/Makefile b/Makefile
index 90c383f..3e66342 100644
--- a/Makefile
+++ b/Makefile
@@ -63,6 +63,7 @@ PDFENGINES_READ_METADATA_ENGINES=exiftool
 PDFENGINES_WRITE_METADATA_ENGINES=exiftool
 PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk
 PDFENGINES_DISABLE_ROUTES=false
+PDFENGINES_EMBED_ENGINES=pdfcpu
 PROMETHEUS_NAMESPACE=gotenberg
 PROMETHEUS_COLLECT_INTERVAL=1s
 PROMETHEUS_DISABLE_ROUTE_LOGGING=false
@@ -137,6 +138,7 @@ run: ## Start a Gotenberg container
 	--pdfengines-write-metadata-engines=$(PDFENGINES_WRITE_METADATA_ENGINES) \
 	--pdfengines-encrypt-engines=$(PDFENGINES_ENCRYPT_ENGINES) \
 	--pdfengines-disable-routes=$(PDFENGINES_DISABLE_ROUTES) \
+	--pdfengines-embed-engines=$(PDFENGINES_EMBED_ENGINES) \
 	--prometheus-namespace=$(PROMETHEUS_NAMESPACE) \
 	--prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \
 	--prometheus-disable-route-logging=$(PROMETHEUS_DISABLE_ROUTE_LOGGING) \
@@ -160,12 +162,13 @@ PLATFORM=
 NO_CONCURRENCY=false
 
 .PHONY: test-integration
-test-integration: ## Run integration tests
+test-integration: ## Run integration tests, use TAGS environment variable to filter tests by tags
 	go test -timeout 40m -tags=integration -v github.com/gotenberg/gotenberg/v8/test/integration -args \
 	--gotenberg-docker-repository=$(DOCKER_REPOSITORY) \
 	--gotenberg-version=$(GOTENBERG_VERSION) \
  	--gotenberg-container-platform=$(PLATFORM) \
- 	--no-concurrency=$(NO_CONCURRENCY)
+ 	--no-concurrency=$(NO_CONCURRENCY) \
+ 	--tags="$(TAGS)"
 
 .PHONY: lint
 lint: ## Lint Golang codebase
diff --git a/go.mod b/go.mod
index 9621294..09bfcae 100644
--- a/go.mod
+++ b/go.mod
@@ -19,7 +19,7 @@ require (
 	github.com/mholt/archives v0.1.5
 	github.com/microcosm-cc/bluemonday v1.0.27
 	github.com/prometheus/client_golang v1.23.2
-	github.com/shirou/gopsutil/v4 v4.25.9
+	github.com/shirou/gopsutil/v4 v4.25.10
 	github.com/spf13/pflag v1.0.10
 	github.com/testcontainers/testcontainers-go v0.39.0
 	go.uber.org/multierr v1.11.0
diff --git a/go.sum b/go.sum
index aba1f53..4136f76 100644
--- a/go.sum
+++ b/go.sum
@@ -275,8 +275,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
-github.com/shirou/gopsutil/v4 v4.25.9 h1:JImNpf6gCVhKgZhtaAHJ0serfFGtlfIlSC08eaKdTrU=
-github.com/shirou/gopsutil/v4 v4.25.9/go.mod h1:gxIxoC+7nQRwUl/xNhutXlD8lq+jxTgpIkEf3rADHL8=
+github.com/shirou/gopsutil/v4 v4.25.10 h1:at8lk/5T1OgtuCp+AwrDofFRjnvosn0nkN2OLQ6g8tA=
+github.com/shirou/gopsutil/v4 v4.25.10/go.mod h1:+kSwyC8DRUD9XXEHCAFjK+0nuArFJM0lva+StQAcskM=
 github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
 github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
 github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
diff --git a/pkg/gotenberg/mocks.go b/pkg/gotenberg/mocks.go
index faa8574..4478256 100644
--- a/pkg/gotenberg/mocks.go
+++ b/pkg/gotenberg/mocks.go
@@ -53,6 +53,7 @@ type PdfEngineMock struct {
 	ReadMetadataMock  func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error)
 	WriteMetadataMock func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error
 	EncryptMock       func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error
+	EmbedFilesMock    func(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error
 }
 
 func (engine *PdfEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
@@ -83,6 +84,10 @@ func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *zap.Logger, in
 	return engine.EncryptMock(ctx, logger, inputPath, userPassword, ownerPassword)
 }
 
+func (engine *PdfEngineMock) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
+	return engine.EmbedFilesMock(ctx, logger, filePaths, inputPath)
+}
+
 // PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface.
 type PdfEngineProviderMock struct {
 	PdfEngineMock func() (PdfEngine, error)
diff --git a/pkg/gotenberg/pdfengine.go b/pkg/gotenberg/pdfengine.go
index e1e37ed..5e9dceb 100644
--- a/pkg/gotenberg/pdfengine.go
+++ b/pkg/gotenberg/pdfengine.go
@@ -143,6 +143,10 @@ type PdfEngine interface {
 	// The ownerPassword provides full access to the document.
 	// If the ownerPassword is empty, it defaults to the userPassword.
 	Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error
+
+	// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
+	// without modifying the main PDF content.
+	EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error
 }
 
 // PdfEngineProvider offers an interface to instantiate a [PdfEngine].
diff --git a/pkg/modules/api/context.go b/pkg/modules/api/context.go
index 61c6dcb..4b390da 100644
--- a/pkg/modules/api/context.go
+++ b/pkg/modules/api/context.go
@@ -38,11 +38,12 @@ var (
 
 // Context is the request context for a "multipart/form-data" requests.
 type Context struct {
-	dirPath     string
-	values      map[string][]string
-	files       map[string]string
-	outputPaths []string
-	cancelled   bool
+	dirPath      string
+	values       map[string][]string
+	files        map[string]string
+	filesByField map[string][]string
+	outputPaths  []string
+	cancelled    bool
 
 	logger     *zap.Logger
 	echoCtx    echo.Context
@@ -79,6 +80,9 @@ type downloadFrom struct {
 
 	// ExtraHttpHeaders are the HTTP headers to send alongside.
 	ExtraHttpHeaders map[string]string `json:"extraHttpHeaders"`
+
+	// Download as embed file
+	Embedded bool `json:"embedded"`
 }
 
 // newContext returns a [Context] by parsing a "multipart/form-data" request.
@@ -184,6 +188,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
 	ctx.dirPath = dirPath
 	ctx.values = form.Value
 	ctx.files = make(map[string]string)
+	ctx.filesByField = make(map[string][]string)
 
 	// First, try to download files listed in the "downloadFrom" form field, if
 	// any.
@@ -318,6 +323,9 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
 				}
 
 				ctx.files[filename] = path
+				if dl.Embedded {
+					ctx.filesByField[EmbedsFormField] = append(ctx.filesByField[EmbedsFormField], path)
+				}
 
 				return nil
 			})
@@ -373,17 +381,22 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
 	}
 
 	// Then, copy the form files, if any.
-	for _, files := range form.File {
+	for fieldName, files := range form.File {
 		for _, fh := range files {
 			err = copyToDisk(fh)
 			if err != nil {
 				return ctx, cancel, fmt.Errorf("copy to disk: %w", err)
 			}
+			// Track files by field name
+			filename := norm.NFC.String(filepath.Base(fh.Filename))
+			filePath := ctx.files[filename]
+			ctx.filesByField[fieldName] = append(ctx.filesByField[fieldName], filePath)
 		}
 	}
 
 	ctx.Log().Debug(fmt.Sprintf("form fields: %+v", ctx.values))
 	ctx.Log().Debug(fmt.Sprintf("form files: %+v", ctx.files))
+	ctx.Log().Debug(fmt.Sprintf("form files by field: %+v", ctx.filesByField))
 	ctx.Log().Debug(fmt.Sprintf("total bytes: %d", totalBytesRead.Load()))
 
 	return ctx, cancel, err
@@ -397,9 +410,10 @@ func (ctx *Context) Request() *http.Request {
 // FormData return a [FormData].
 func (ctx *Context) FormData() *FormData {
 	return &FormData{
-		values: ctx.values,
-		files:  ctx.files,
-		errors: nil,
+		values:       ctx.values,
+		files:        ctx.files,
+		filesByField: ctx.filesByField,
+		errors:       nil,
 	}
 }
 
diff --git a/pkg/modules/api/formdata.go b/pkg/modules/api/formdata.go
index fd6b664..d2d5bf7 100644
--- a/pkg/modules/api/formdata.go
+++ b/pkg/modules/api/formdata.go
@@ -6,6 +6,7 @@ import (
 	"net/http"
 	"os"
 	"path/filepath"
+	"slices"
 	"sort"
 	"strconv"
 	"strings"
@@ -16,14 +17,20 @@ import (
 	"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
 )
 
+// EmbedsFormField represents the form field name for embedding files.
+const (
+	EmbedsFormField string = "embeds"
+)
+
 // FormData is a helper for validating and hydrating values from a
 // "multipart/form-data" request.
 //
 //	form := ctx.FormData()
 type FormData struct {
-	values map[string][]string
-	files  map[string]string
-	errors error
+	values       map[string][]string
+	files        map[string]string
+	filesByField map[string][]string
+	errors       error
 }
 
 // Validate returns nil or an error related to the [FormData] values, with a
@@ -358,6 +365,26 @@ func (form *FormData) Paths(extensions []string, target *[]string) *FormData {
 	return form.paths(extensions, target)
 }
 
+// Embeds binds the absolute paths of form data files that should be
+// embedded in the PDF. Only files uploaded with the "embeds" field name
+// will be included.
+//
+//	var embeds []string
+//
+//	ctx.FormData().Embeds(&embeds)
+func (form *FormData) Embeds(target *[]string) *FormData {
+	if form.errors != nil {
+		return form
+	}
+
+	// Get files from the "embeds" field
+	if paths, ok := form.filesByField[EmbedsFormField]; ok {
+		*target = append(*target, paths...)
+	}
+
+	return form
+}
+
 // MandatoryPaths binds the absolute paths of form data files, according to a
 // list of file extensions, to a string slice variable. It populates an error
 // if there is no file for given file extensions.
@@ -381,8 +408,15 @@ func (form *FormData) MandatoryPaths(extensions []string, target *[]string) *For
 
 // paths bind the absolute paths of form data files, according to a list of
 // file extensions, to a string slice variable.
+// embeds are excluded.
 func (form *FormData) paths(extensions []string, target *[]string) *FormData {
+	embeds, ok := form.filesByField[EmbedsFormField]
+
 	for filename, path := range form.files {
+		if ok && slices.Contains(embeds, path) {
+			continue
+		}
+
 		for _, ext := range extensions {
 			// See https://github.com/gotenberg/gotenberg/issues/228.
 			if strings.ToLower(filepath.Ext(filename)) == ext {
diff --git a/pkg/modules/chromium/routes.go b/pkg/modules/chromium/routes.go
index e096eab..a756bc1 100644
--- a/pkg/modules/chromium/routes.go
+++ b/pkg/modules/chromium/routes.go
@@ -361,6 +361,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
 			pdfFormats := pdfengines.FormDataPdfFormats(form)
 			metadata := pdfengines.FormDataPdfMetadata(form, false)
 			userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
+			embedPaths := pdfengines.FormDataPdfEmbeds(form)
 
 			var url string
 			err := form.
@@ -370,7 +371,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
 				return fmt.Errorf("validate form data: %w", err)
 			}
 
-			err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword)
+			err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths)
 			if err != nil {
 				return fmt.Errorf("convert URL to PDF: %w", err)
 			}
@@ -423,6 +424,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
 			pdfFormats := pdfengines.FormDataPdfFormats(form)
 			metadata := pdfengines.FormDataPdfMetadata(form, false)
 			userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
+			embedPaths := pdfengines.FormDataPdfEmbeds(form)
 
 			var inputPath string
 			err := form.
@@ -433,7 +435,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
 			}
 
 			url := fmt.Sprintf("file://%s", inputPath)
-			err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword)
+			err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths)
 			if err != nil {
 				return fmt.Errorf("convert HTML to PDF: %w", err)
 			}
@@ -487,6 +489,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
 			pdfFormats := pdfengines.FormDataPdfFormats(form)
 			metadata := pdfengines.FormDataPdfMetadata(form, false)
 			userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
+			embedPaths := pdfengines.FormDataPdfEmbeds(form)
 
 			var (
 				inputPath     string
@@ -506,7 +509,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
 				return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
 			}
 
-			err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword)
+			err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths)
 			if err != nil {
 				return fmt.Errorf("convert markdown to PDF: %w", err)
 			}
@@ -630,7 +633,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
 	return fmt.Sprintf("file://%s", inputPath), nil
 }
 
-func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]interface{}, userPassword, ownerPassword string) error {
+func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]interface{}, userPassword, ownerPassword string, embedPaths []string) error {
 	outputPath := ctx.GeneratePath(".pdf")
 	// See https://github.com/gotenberg/gotenberg/issues/1130.
 	filename := ctx.OutputFilename(outputPath)
@@ -687,6 +690,11 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
 		return fmt.Errorf("write metadata: %w", err)
 	}
 
+	err = pdfengines.EmbedFilesStub(ctx, engine, embedPaths, convertOutputPaths)
+	if err != nil {
+		return fmt.Errorf("embed files into PDFs: %w", err)
+	}
+
 	err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
 	if err != nil {
 		return fmt.Errorf("encrypt PDFs: %w", err)
diff --git a/pkg/modules/exiftool/exiftool.go b/pkg/modules/exiftool/exiftool.go
index ae33e88..d00127c 100644
--- a/pkg/modules/exiftool/exiftool.go
+++ b/pkg/modules/exiftool/exiftool.go
@@ -181,6 +181,11 @@ func (engine *ExifTool) Encrypt(ctx context.Context, logger *zap.Logger, inputPa
 	return fmt.Errorf("encrypt PDF using ExifTool: %w", gotenberg.ErrPdfEncryptionNotSupported)
 }
 
+// EmbedFiles is not available in this implementation.
+func (engine *ExifTool) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
+	return fmt.Errorf("embed files with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
+}
+
 // Interface guards.
 var (
 	_ gotenberg.Module      = (*ExifTool)(nil)
diff --git a/pkg/modules/libreoffice/pdfengine/pdfengine.go b/pkg/modules/libreoffice/pdfengine/pdfengine.go
index e47d9b8..e5a5c66 100644
--- a/pkg/modules/libreoffice/pdfengine/pdfengine.go
+++ b/pkg/modules/libreoffice/pdfengine/pdfengine.go
@@ -96,6 +96,11 @@ func (engine *LibreOfficePdfEngine) Encrypt(ctx context.Context, logger *zap.Log
 	return fmt.Errorf("encrypt PDF using LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
 }
 
+// EmbedFiles is not available in this implementation.
+func (engine *LibreOfficePdfEngine) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
+	return fmt.Errorf("embed files with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
+}
+
 // Interface guards.
 var (
 	_ gotenberg.Module      = (*LibreOfficePdfEngine)(nil)
diff --git a/pkg/modules/libreoffice/routes.go b/pkg/modules/libreoffice/routes.go
index 7acaea7..cc96a4d 100644
--- a/pkg/modules/libreoffice/routes.go
+++ b/pkg/modules/libreoffice/routes.go
@@ -31,6 +31,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
 			pdfFormats := pdfengines.FormDataPdfFormats(form)
 			metadata := pdfengines.FormDataPdfMetadata(form, false)
 			userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
+			embedPaths := pdfengines.FormDataPdfEmbeds(form)
 
 			zeroValuedSplitMode := gotenberg.SplitMode{}
 
@@ -264,6 +265,11 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
 				}
 			}
 
+			err = pdfengines.EmbedFilesStub(ctx, engine, embedPaths, outputPaths)
+			if err != nil {
+				return fmt.Errorf("embed files into PDFs: %w", err)
+			}
+
 			err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)
 			if err != nil {
 				return fmt.Errorf("encrypt PDFs: %w", err)
diff --git a/pkg/modules/pdfcpu/pdfcpu.go b/pkg/modules/pdfcpu/pdfcpu.go
index 97e25f5..37f0d25 100644
--- a/pkg/modules/pdfcpu/pdfcpu.go
+++ b/pkg/modules/pdfcpu/pdfcpu.go
@@ -171,6 +171,34 @@ func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *zap.Logger, met
 	return fmt.Errorf("write PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
 }
 
+// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
+// without modifying the main PDF content.
+func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
+	if len(filePaths) == 0 {
+		return nil
+	}
+
+	logger.Debug(fmt.Sprintf("embedding %d file(s) to %s: %v", len(filePaths), inputPath, filePaths))
+
+	args := []string{
+		"attachments", "add",
+		inputPath,
+	}
+	args = append(args, filePaths...)
+
+	cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
+	if err != nil {
+		return fmt.Errorf("create command for attaching files: %w", err)
+	}
+
+	_, err = cmd.Exec()
+	if err != nil {
+		return fmt.Errorf("attach files with pdfcpu: %w", err)
+	}
+
+	return nil
+}
+
 // Encrypt adds password protection to a PDF file using pdfcpu.
 func (engine *PdfCpu) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
 	if userPassword == "" {
diff --git a/pkg/modules/pdfengines/multi.go b/pkg/modules/pdfengines/multi.go
index 78a5277..5726a2b 100644
--- a/pkg/modules/pdfengines/multi.go
+++ b/pkg/modules/pdfengines/multi.go
@@ -19,6 +19,7 @@ type multiPdfEngines struct {
 	readMetadataEngines  []gotenberg.PdfEngine
 	writeMetadataEngines []gotenberg.PdfEngine
 	passwordEngines      []gotenberg.PdfEngine
+	embedEngines         []gotenberg.PdfEngine
 }
 
 func newMultiPdfEngines(
@@ -28,7 +29,8 @@ func newMultiPdfEngines(
 	convertEngines,
 	readMetadataEngines,
 	writeMetadataEngines,
-	passwordEngines []gotenberg.PdfEngine,
+	passwordEngines,
+	embedEngines []gotenberg.PdfEngine,
 ) *multiPdfEngines {
 	return &multiPdfEngines{
 		mergeEngines:         mergeEngines,
@@ -38,6 +40,7 @@ func newMultiPdfEngines(
 		readMetadataEngines:  readMetadataEngines,
 		writeMetadataEngines: writeMetadataEngines,
 		passwordEngines:      passwordEngines,
+		embedEngines:         embedEngines,
 	}
 }
 
@@ -238,6 +241,31 @@ func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *zap.Logger, i
 	return fmt.Errorf("encrypt PDF using multi PDF engines: %w", err)
 }
 
+// EmbedFiles embeds files into a PDF using the first available
+// engine that supports file embedding.
+func (multi *multiPdfEngines) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
+	var err error
+	errChan := make(chan error, 1)
+
+	for _, engine := range multi.embedEngines {
+		go func(engine gotenberg.PdfEngine) {
+			errChan <- engine.EmbedFiles(ctx, logger, filePaths, inputPath)
+		}(engine)
+
+		select {
+		case embedErr := <-errChan:
+			errored := multierr.AppendInto(&err, embedErr)
+			if !errored {
+				return nil
+			}
+		case <-ctx.Done():
+			return ctx.Err()
+		}
+	}
+
+	return fmt.Errorf("embed files into PDF using multi PDF engines: %w", err)
+}
+
 // Interface guards.
 var (
 	_ gotenberg.PdfEngine = (*multiPdfEngines)(nil)
diff --git a/pkg/modules/pdfengines/pdfengines.go b/pkg/modules/pdfengines/pdfengines.go
index 8e3bc17..6fd70ec 100644
--- a/pkg/modules/pdfengines/pdfengines.go
+++ b/pkg/modules/pdfengines/pdfengines.go
@@ -34,6 +34,7 @@ type PdfEngines struct {
 	readMetadataNames  []string
 	writeMetadataNames []string
 	encryptNames       []string
+	embedNames         []string
 	engines            []gotenberg.PdfEngine
 	disableRoutes      bool
 }
@@ -51,6 +52,7 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
 			fs.StringSlice("pdfengines-read-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the read metadata feature - empty means all")
 			fs.StringSlice("pdfengines-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature - empty means all")
 			fs.StringSlice("pdfengines-encrypt-engines", []string{"qpdf", "pdftk", "pdfcpu"}, "Set the PDF engines and their order for the password protection feature - empty means all")
+			fs.StringSlice("pdfengines-embed-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the file embedding feature - empty means all")
 			fs.Bool("pdfengines-disable-routes", false, "Disable the routes")
 
 			// Deprecated flags.
@@ -77,6 +79,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
 	readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines")
 	writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines")
 	encryptNames := flags.MustStringSlice("pdfengines-encrypt-engines")
+	embedNames := flags.MustStringSlice("pdfengines-embed-engines")
 	mod.disableRoutes = flags.MustBool("pdfengines-disable-routes")
 
 	engines, err := ctx.Modules(new(gotenberg.PdfEngine))
@@ -138,6 +141,11 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
 		mod.encryptNames = encryptNames
 	}
 
+	mod.embedNames = defaultNames
+	if len(embedNames) > 0 {
+		mod.embedNames = embedNames
+	}
+
 	return nil
 }
 
@@ -192,6 +200,7 @@ func (mod *PdfEngines) Validate() error {
 	findNonExistingEngines(mod.readMetadataNames)
 	findNonExistingEngines(mod.writeMetadataNames)
 	findNonExistingEngines(mod.encryptNames)
+	findNonExistingEngines(mod.embedNames)
 
 	if len(nonExistingEngines) == 0 {
 		return nil
@@ -238,6 +247,7 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
 		engines(mod.readMetadataNames),
 		engines(mod.writeMetadataNames),
 		engines(mod.encryptNames),
+		engines(mod.embedNames),
 	), nil
 }
 
@@ -262,6 +272,7 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
 		readMetadataRoute(engine),
 		writeMetadataRoute(engine),
 		encryptRoute(engine),
+		embedRoute(engine),
 	}, nil
 }
 
diff --git a/pkg/modules/pdfengines/routes.go b/pkg/modules/pdfengines/routes.go
index ce9135c..27e536c 100644
--- a/pkg/modules/pdfengines/routes.go
+++ b/pkg/modules/pdfengines/routes.go
@@ -254,6 +254,14 @@ func WriteMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metadata ma
 	return nil
 }
 
+// FormDataPdfEmbeds extracts embedded file paths from form data.
+// Only files uploaded with the "embeds" field name are included.
+func FormDataPdfEmbeds(form *api.FormData) []string {
+	var embedPaths []string
+	form.Embeds(&embedPaths)
+	return embedPaths
+}
+
 // FormDataPdfEncrypt extracts encryption parameters from form data.
 func FormDataPdfEncrypt(form *api.FormData) (userPassword, ownerPassword string) {
 	form.String("userPassword", &userPassword, "")
@@ -277,6 +285,22 @@ func EncryptPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, userPassword,
 	return nil
 }
 
+// EmbedFilesStub embeds files into PDF files.
+func EmbedFilesStub(ctx *api.Context, engine gotenberg.PdfEngine, embedPaths []string, inputPaths []string) error {
+	if len(embedPaths) == 0 {
+		return nil
+	}
+
+	for _, inputPath := range inputPaths {
+		err := engine.EmbedFiles(ctx, ctx.Log(), embedPaths, inputPath)
+		if err != nil {
+			return fmt.Errorf("embed files into PDF '%s': %w", inputPath, err)
+		}
+	}
+
+	return nil
+}
+
 // mergeRoute returns an [api.Route] which can merge PDFs.
 func mergeRoute(engine gotenberg.PdfEngine) api.Route {
 	return api.Route{
@@ -290,6 +314,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
 			pdfFormats := FormDataPdfFormats(form)
 			metadata := FormDataPdfMetadata(form, false)
 			userPassword, ownerPassword := FormDataPdfEncrypt(form)
+			embedPaths := FormDataPdfEmbeds(form)
 
 			var inputPaths []string
 			var flatten bool
@@ -324,6 +349,11 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
 				}
 			}
 
+			err = EmbedFilesStub(ctx, engine, embedPaths, outputPaths)
+			if err != nil {
+				return fmt.Errorf("embed files into PDFs: %w", err)
+			}
+
 			err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)
 			if err != nil {
 				return fmt.Errorf("encrypt PDFs: %w", err)
@@ -353,6 +383,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
 			pdfFormats := FormDataPdfFormats(form)
 			metadata := FormDataPdfMetadata(form, false)
 			userPassword, ownerPassword := FormDataPdfEncrypt(form)
+			embedPaths := FormDataPdfEmbeds(form)
 
 			var inputPaths []string
 			var flatten bool
@@ -386,6 +417,11 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
 				}
 			}
 
+			err = EmbedFilesStub(ctx, engine, embedPaths, convertOutputPaths)
+			if err != nil {
+				return fmt.Errorf("embed files into PDFs: %w", err)
+			}
+
 			err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
 			if err != nil {
 				return fmt.Errorf("encrypt PDFs: %w", err)
@@ -620,3 +656,38 @@ func encryptRoute(engine gotenberg.PdfEngine) api.Route {
 		},
 	}
 }
+
+// embedRoute returns an [api.Route] which can add embedded files to PDFs.
+func embedRoute(engine gotenberg.PdfEngine) api.Route {
+	return api.Route{
+		Method:      http.MethodPost,
+		Path:        "/forms/pdfengines/embed",
+		IsMultipart: true,
+		Handler: func(c echo.Context) error {
+			ctx := c.Get("context").(*api.Context)
+
+			form := ctx.FormData()
+			embedPaths := FormDataPdfEmbeds(form)
+
+			var inputPaths []string
+			err := form.
+				MandatoryPaths([]string{".pdf"}, &inputPaths).
+				Validate()
+			if err != nil {
+				return fmt.Errorf("validate form data: %w", err)
+			}
+
+			err = EmbedFilesStub(ctx, engine, embedPaths, inputPaths)
+			if err != nil {
+				return fmt.Errorf("embed files into PDFs: %w", err)
+			}
+
+			err = ctx.AddOutputPaths(inputPaths...)
+			if err != nil {
+				return fmt.Errorf("add output paths: %w", err)
+			}
+
+			return nil
+		},
+	}
+}
diff --git a/pkg/modules/pdftk/pdftk.go b/pkg/modules/pdftk/pdftk.go
index 655cbd4..f5c112b 100644
--- a/pkg/modules/pdftk/pdftk.go
+++ b/pkg/modules/pdftk/pdftk.go
@@ -183,6 +183,11 @@ func (engine *PdfTk) Encrypt(ctx context.Context, logger *zap.Logger, inputPath,
 	return nil
 }
 
+// EmbedFiles is not available in this implementation.
+func (engine *PdfTk) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
+	return fmt.Errorf("embed files with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
+}
+
 // Interface guards.
 var (
 	_ gotenberg.Module      = (*PdfTk)(nil)
diff --git a/pkg/modules/qpdf/qpdf.go b/pkg/modules/qpdf/qpdf.go
index f2aa2e3..c850798 100644
--- a/pkg/modules/qpdf/qpdf.go
+++ b/pkg/modules/qpdf/qpdf.go
@@ -201,6 +201,11 @@ func (engine *QPdf) Encrypt(ctx context.Context, logger *zap.Logger, inputPath,
 	return nil
 }
 
+// EmbedFiles is not available in this implementation.
+func (engine *QPdf) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
+	return fmt.Errorf("embed files with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
+}
+
 var (
 	_ gotenberg.Module      = (*QPdf)(nil)
 	_ gotenberg.Provisioner = (*QPdf)(nil)



Test patch:
diff --git a/pkg/modules/api/formdata_test.go b/pkg/modules/api/formdata_test.go
index e3ce8f4..ca57583 100644
--- a/pkg/modules/api/formdata_test.go
+++ b/pkg/modules/api/formdata_test.go
@@ -1612,6 +1612,24 @@ func TestFormData_Paths(t *testing.T) {
 			},
 			expectCount: 2,
 		},
+		{
+			scenario: "files except embeds",
+			form: &FormData{
+				files: map[string]string{
+					"foo.pdf":     "/foo.pdf",
+					"embed_1.pdf": "/embed_1.pdf",
+					"embed_2.xml": "/embed_2.xml",
+				},
+				filesByField: map[string][]string{
+					"embeds": {"/embed_1.pdf", "/embed_2.xml"},
+				},
+			},
+			extensions: []string{".pdf"},
+			expect: []string{
+				"/foo.pdf",
+			},
+			expectCount: 1,
+		},
 	} {
 		t.Run(tc.scenario, func(t *testing.T) {
 			var actual []string
@@ -1740,3 +1758,28 @@ func TestFormData_mustAssign(t *testing.T) {
 	var target []string
 	form.mustAssign("foo", "foo", &target)
 }
+
+func TestFormData_Embeds(t *testing.T) {
+	expected := []string{"/bar.xml", "/baz.xml"}
+
+	var actual []string
+	form := &FormData{
+		files: map[string]string{
+			"foo.pdf": "/foo.pdf",
+			"bar.xml": "/bar.xml",
+			"baz.xml": "/baz.xml",
+		},
+		filesByField: map[string][]string{
+			"embeds": {"/bar.xml", "/baz.xml"},
+		},
+	}
+	form.Embeds(&actual)
+
+	if len(actual) != len(expected) {
+		t.Errorf("expected %d embeds but got %d", len(expected), len(actual))
+	}
+
+	if !reflect.DeepEqual(actual, expected) {
+		t.Errorf("expected %v but got %v", expected, actual)
+	}
+}
diff --git a/test/integration/features/chromium_convert_html.feature b/test/integration/features/chromium_convert_html.feature
index 9b6ea65..32eb2c0 100644
--- a/test/integration/features/chromium_convert_html.feature
+++ b/test/integration/features/chromium_convert_html.feature
@@ -942,3 +942,19 @@ Feature: /forms/chromium/convert/html
       | files | testdata/page-1-html/index.html | file |
     Then the response status code should be 200
     Then the response header "Content-Type" should be "application/pdf"
+
+  @embed
+  Scenario: POST /forms/chromium/convert/html (Embeds)
+    Given I have a default Gotenberg container
+    When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
+      | files                     | testdata/page-1-html/index.html | file   |
+      | embeds                    | testdata/embed_1.xml            | file   |
+      | embeds                    | testdata/embed_2.xml            | file   |
+      | Gotenberg-Output-Filename | foo                             | header |
+    Then the response status code should be 200
+    And the response header "Content-Type" should be "application/pdf"
+    And there should be 1 PDF(s) in the response
+    And there should be the following file(s) in the response:
+      | foo.pdf |
+    And the "foo.pdf" PDF should have the "embed_1.xml" file embedded in it
+    And the "foo.pdf" PDF should have the "embed_2.xml" file embedded in it
diff --git a/test/integration/features/chromium_convert_markdown.feature b/test/integration/features/chromium_convert_markdown.feature
index dd415ca..e70eb85 100644
--- a/test/integration/features/chromium_convert_markdown.feature
+++ b/test/integration/features/chromium_convert_markdown.feature
@@ -1075,3 +1075,20 @@ Feature: /forms/chromium/convert/markdown
       | files | testdata/page-1-markdown/page_1.md  | file |
     Then the response status code should be 200
     Then the response header "Content-Type" should be "application/pdf"
+
+  @embed
+  Scenario: POST /forms/chromium/convert/markdown (Embeds)
+    Given I have a default Gotenberg container
+    When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
+      | files                     | testdata/page-1-markdown/index.html | file   |
+      | files                     | testdata/page-1-markdown/page_1.md  | file   |
+      | embeds                    | testdata/embed_1.xml                | file   |
+      | embeds                    | testdata/embed_2.xml                | file   |
+      | Gotenberg-Output-Filename | foo                                 | header |
+    Then the response status code should be 200
+    And the response header "Content-Type" should be "application/pdf"
+    And there should be 1 PDF(s) in the response
+    And there should be the following file(s) in the response:
+      | foo.pdf |
+    And the "foo.pdf" PDF should have the "embed_1.xml" file embedded in it
+    And the "foo.pdf" PDF should have the "embed_2.xml" file embedded in it
diff --git a/test/integration/features/chromium_convert_url.feature b/test/integration/features/chromium_convert_url.feature
index 319f1aa..786caa8 100644
--- a/test/integration/features/chromium_convert_url.feature
+++ b/test/integration/features/chromium_convert_url.feature
@@ -1039,3 +1039,20 @@ Feature: /forms/chromium/convert/url
       | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
     Then the response status code should be 200
     Then the response header "Content-Type" should be "application/pdf"
+
+  @embed
+  Scenario: POST /foo/forms/chromium/convert/url (Embeds)
+    Given I have a default Gotenberg container
+    And I have a static server
+    When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
+      | embeds                    | testdata/embed_1.xml                                                | file   |
+      | embeds                    | testdata/embed_2.xml                                                | file   |
+      | url                       | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field  |
+      | Gotenberg-Output-Filename | foo                                                                 | header |
+    Then the response status code should be 200
+    And the response header "Content-Type" should be "application/pdf"
+    And there should be 1 PDF(s) in the response
+    And there should be the following file(s) in the webhook request:
+      | foo.pdf |
+    And the "foo.pdf" PDF should have the "embed_1.xml" file embedded in it
+    And the "foo.pdf" PDF should have the "embed_2.xml" file embedded in it
diff --git a/test/integration/features/libreoffice_convert.feature b/test/integration/features/libreoffice_convert.feature
index 20c903e..84ffb04 100644
--- a/test/integration/features/libreoffice_convert.feature
+++ b/test/integration/features/libreoffice_convert.feature
@@ -652,3 +652,19 @@ Feature: /forms/libreoffice/convert
       | files | testdata/page_1.docx | file |
     Then the response status code should be 200
     Then the response header "Content-Type" should be "application/pdf"
+
+  @embed
+  Scenario: POST /forms/libreoffice/convert (Embeds)
+    Given I have a default Gotenberg container
+    When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
+      | files                     | testdata/page_1.docx | file   |
+      | embeds                    | testdata/embed_1.xml | file   |
+      | embeds                    | testdata/embed_2.xml | file   |
+      | Gotenberg-Output-Filename | foo                  | header |
+    Then the response status code should be 200
+    And the response header "Content-Type" should be "application/pdf"
+    And there should be 1 PDF(s) in the response
+    And there should be the following file(s) in the response:
+      | foo.pdf |
+    And the "foo.pdf" PDF should have the "embed_1.xml" file embedded in it
+    And the "foo.pdf" PDF should have the "embed_2.xml" file embedded in it
diff --git a/test/integration/features/pdfengines_embeds.feature b/test/integration/features/pdfengines_embeds.feature
new file mode 100644
index 0000000..4ae4046
--- /dev/null
+++ b/test/integration/features/pdfengines_embeds.feature
@@ -0,0 +1,32 @@
+@embed
+Feature: /forms/pdfengines/embed
+
+  Scenario: POST /forms/pdfengines/embed
+    Given I have a default Gotenberg container
+    When I make a "POST" request to Gotenberg at the "/forms/pdfengines/embed" endpoint with the following form data and header(s):
+      | files  | testdata/page_1.pdf  | file |
+      | embeds | testdata/embed_1.xml | file |
+      | embeds | testdata/embed_2.xml | file |
+      | embeds | testdata/page_2.pdf  | file |
+    Then the response status code should be 200
+    And the response header "Content-Type" should be "application/pdf"
+    And there should be 1 PDF(s) in the response
+    And there should be the following file(s) in the response:
+      | page_1.pdf |
+    And the "page_1.pdf" PDF should have the "embed_1.xml" file embedded in it
+    And the "page_1.pdf" PDF should have the "embed_2.xml" file embedded in it
+    And the "page_1.pdf" PDF should have the "page_2.pdf" file embedded in it
+
+  Scenario: POST /forms/pdfengines/embed with (Download From)
+    Given I have a default Gotenberg container
+    And I have a static server
+    When I make a "POST" request to Gotenberg at the "/forms/pdfengines/embed" endpoint with the following form data and header(s):
+      | files        | testdata/page_1.pdf                                                                                                                                                              | file  |
+      | downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/embed_1.xml", "embedded": true},{"url":"http://host.docker.internal:%d/static/testdata/embed_2.xml", "embedded": false}] | field |
+    Then the response status code should be 200
+    And the response header "Content-Type" should be "application/pdf"
+    And there should be 1 PDF(s) in the response
+    And there should be the following file(s) in the response:
+      | page_1.pdf |
+    And the "page_1.pdf" PDF should have the "embed_1.xml" file embedded in it
+    And the "page_1.pdf" PDF should NOT have the "embed_2.xml" file embedded in it
diff --git a/test/integration/features/pdfengines_merge.feature b/test/integration/features/pdfengines_merge.feature
index 54005dd..25a6110 100644
--- a/test/integration/features/pdfengines_merge.feature
+++ b/test/integration/features/pdfengines_merge.feature
@@ -368,3 +368,16 @@ Feature: /forms/pdfengines/merge
       | files | testdata/page_2.pdf | file |
     Then the response status code should be 200
     Then the response header "Content-Type" should be "application/pdf"
+
+  @embed
+  Scenario: POST /foo/forms/pdfengines/merge (Embeds)
+    Given I have a default Gotenberg container
+    When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
+      | files                     | testdata/page_1.pdf  | file   |
+      | files                     | testdata/page_2.pdf  | file   |
+      | embeds                    | testdata/embed_1.xml | file   |
+      | embeds                    | testdata/embed_2.xml | file   |
+      | Gotenberg-Output-Filename | foo                  | header |
+    Then the response status code should be 200
+    And the "foo.pdf" PDF should have the "embed_1.xml" file embedded in it
+    And the "foo.pdf" PDF should have the "embed_2.xml" file embedded in it
diff --git a/test/integration/features/pdfengines_split.feature b/test/integration/features/pdfengines_split.feature
index 3566f52..d988510 100644
--- a/test/integration/features/pdfengines_split.feature
+++ b/test/integration/features/pdfengines_split.feature
@@ -650,3 +650,23 @@ Feature: /forms/pdfengines/split
       | splitSpan | 2                    | field |
     Then the response status code should be 200
     Then the response header "Content-Type" should be "application/zip"
+
+  @embed
+  Scenario: POST /foo/forms/pdfengines/split (Embeds)
+    Given I have a default Gotenberg container
+    When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s):
+      | files     | testdata/pages_3.pdf | file  |
+      | embeds    | testdata/embed_1.xml | file  |
+      | embeds    | testdata/embed_2.xml | file  |
+      | splitMode | intervals            | field |
+      | splitSpan | 2                    | field |
+    Then the response status code should be 200
+    And the response header "Content-Type" should be "application/zip"
+    And there should be 2 PDF(s) in the response
+    And there should be the following file(s) in the response:
+      | pages_3_0.pdf |
+      | pages_3_1.pdf |
+    And the "pages_3_0.pdf" PDF should have the "embed_1.xml" file embedded in it
+    And the "pages_3_0.pdf" PDF should have the "embed_2.xml" file embedded in it
+    And the "pages_3_1.pdf" PDF should have the "embed_1.xml" file embedded in it
+    And the "pages_3_1.pdf" PDF should have the "embed_2.xml" file embedded in it
diff --git a/test/integration/main_test.go b/test/integration/main_test.go
index 6e4036c..67144b2 100644
--- a/test/integration/main_test.go
+++ b/test/integration/main_test.go
@@ -19,6 +19,7 @@ func TestMain(m *testing.M) {
 	version := flag.String("gotenberg-version", "", "")
 	platform := flag.String("gotenberg-container-platform", "", "")
 	noConcurrency := flag.Bool("no-concurrency", false, "")
+	tags := flag.String("tags", "", "")
 	flag.Parse()
 
 	if *platform == "" {
@@ -47,6 +48,7 @@ func TestMain(m *testing.M) {
 			Paths:       []string{"features"},
 			Output:      colors.Colored(os.Stdout),
 			Concurrency: concurrency,
+			Tags:        *tags,
 		},
 	}.Run()
 
diff --git a/test/integration/scenario/scenario.go b/test/integration/scenario/scenario.go
index 4852e38..222bd58 100644
--- a/test/integration/scenario/scenario.go
+++ b/test/integration/scenario/scenario.go
@@ -735,6 +735,41 @@ func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context,
 	return nil
 }
 
+func (s *scenario) thePdfShouldHaveTheFollowingEmbedsInIt(ctx context.Context, name, should string, embed string) error {
+	path, err := s.getPath(name)
+	if err != nil {
+		return fmt.Errorf("get path %q: %w", name, err)
+	}
+	invert := should == "should NOT"
+
+	cmd := []string{
+		"verapdf",
+		"--off",
+		"--loglevel",
+		"0",
+		"--extract",
+		"embeddedFile",
+		name,
+	}
+
+	output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
+	if err != nil {
+		return fmt.Errorf("exec %q: %w", cmd, err)
+	}
+
+	found := strings.Contains(output, fmt.Sprintf("<fileName>%s</fileName>", embed))
+
+	if invert && found {
+		return fmt.Errorf("embed %q found", embed)
+	}
+
+	if !invert && !found {
+		return fmt.Errorf("embed %q not found", embed)
+	}
+
+	return nil
+}
+
 func (s *scenario) thePdfShouldHaveTheFollowingContentAtPage(ctx context.Context, name, kind string, page int, expected *godog.DocString) error {
 	var path string
 	if !strings.HasPrefix(name, "*_") {
@@ -927,6 +962,7 @@ func InitializeScenario(ctx *godog.ScenarioContext) {
 	ctx.Then(`^the "([^"]*)" PDF should have (\d+) page\(s\)$`, s.thePdfShouldHavePages)
 	ctx.Then(`^the "([^"]*)" PDF (should|should NOT) be set to landscape orientation$`, s.thePdfShouldBeSetToLandscapeOrientation)
 	ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage)
+	ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the "([^"]*)" file embedded in it$`, s.thePdfShouldHaveTheFollowingEmbedsInIt)
 	ctx.After(func(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) {
 		if s.gotenbergContainer != nil {
 			errTerminate := s.gotenbergContainer.Terminate(ctx, testcontainers.StopTimeout(0))
@@ -950,3 +986,14 @@ func InitializeScenario(ctx *godog.ScenarioContext) {
 		return ctx, nil
 	})
 }
+
+func (s *scenario) getPath(name string) (string, error) {
+	path := fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name)
+
+	_, err := os.Stat(path)
+	if os.IsNotExist(err) {
+		return "", fmt.Errorf("PDF %q does not exist", path)
+	}
+
+	return path, nil
+}
diff --git a/test/integration/testdata/embed_1.xml b/test/integration/testdata/embed_1.xml
new file mode 100644
index 0000000..acd3c47
--- /dev/null
+++ b/test/integration/testdata/embed_1.xml
@@ -0,0 +1,5 @@
+<xml>
+    <test>test 1.1</test>
+    <test>test 1.2</test>
+    <test>test 1.3</test>
+</xml>
\ No newline at end of file
diff --git a/test/integration/testdata/embed_2.xml b/test/integration/testdata/embed_2.xml
new file mode 100644
index 0000000..44ff7b5
--- /dev/null
+++ b/test/integration/testdata/embed_2.xml
@@ -0,0 +1,5 @@
+<xml>
+    <test>test 2.1</test>
+    <test>test 2.2</test>
+    <test>test 2.3</test>
+</xml>
\ No newline at end of file