Repository: Kozea/WeasyPrint
Pull request #2338 (2024-12-31 14:21:37)

custom RDF metadata for PDF/A, especially for Factur-X eInvoices
This MR adds a way to add custom RDF metadata for PDF/A s. It's implemented using a custom generator function, because RDF extensions get pretty difficult, and usually you want to write an own generator.

Background: EN 16931 / ZUGFeRD / Factur-X eInvoices require a custom RDF metadata extension to be valid, something like this: https://www.pdflib.com/fileadmin/pdf-knowledge-base/zugferd/Factur-X_extension_schema.xmp . This cannot be added with the existing mechanisms. So far, we monkey-patched weasyprint to generate valid eInvoices, which works, but is unstable and not the way how it should be done. Therefore, this MR. :)

Larger background: Germany enforces eInvoices starting 01.01.2025 for B2B customers with a transition period of two years. Other EU countries have similar plans. Therefore, there will be a growing interest for proper FOSS tools. :)

I will write a blog article how to use weasyprint for eInvoices as soon as I don't have to recommend monkeypatching. I would be fine using parts of the blog article in weasyprints documentation, too.

I hope the MR is ok for you as this is my first MR in this project. I tried to stay close to your code style, but added typing as I think it makes sense to do so "on the way". If you want any changes or additional documentation, feel free to request it.

liZe: Hi!

Thanks for your PR, supporting ZUGFeRD / Factur-X could be a really good thing for WeasyPrint!

Everything seems to be OK with this code, I’ve only changed a couple of minor things (mainly removed types that we don’t want to use in WeasyPrint). Could I have rights to push the commit to your branch? (Or I can start another branch from your commit and create a new PR if you prefer.)

Before merging, could you please share some code you could use to generate Factur-X documents? We’d like to be sure about the API we’ll have to maintain. :smile: If you already have some content written for your blog post, don’t hesitate to share, we could add some documentation in this PR too.
the-infinity: Just invited you to the repository.

May I ask why you don't use typing? That's a bit uncommon :)

Wanted to start writing the blog post as soon as I got a positive reaction from this MR, so I will start now. The method I would set at `rdf_metadata_generator()` would be `generate_metadata()` from this generator class:

```

from lxml import builder, etree
from weasyprint import __version__ as weasyprint_version


class RdfMetadataGenerator:
    """
    Generates XMP / RDF metadata for PDF/A-3b documents.
    """

    nsmap: dict[str, str] = {
        'rdf': 'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
        'fx': 'urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#',
        'pdf': 'http://ns.adobe.com/pdf/1.3/',
        'pdfaid': 'http://www.aiim.org/pdfa/ns/id/',
        'pdfaExtension': 'http://www.aiim.org/pdfa/ns/extension/',
        'pdfaSchema': 'http://www.aiim.org/pdfa/ns/schema#',
        'pdfaProperty': 'http://www.aiim.org/pdfa/ns/property#',
    }

    em_rdf = builder.ElementMaker(namespace=nsmap['rdf'], nsmap=nsmap)
    em_fx = builder.ElementMaker(namespace=nsmap['fx'])
    em_pdf = builder.ElementMaker(namespace=nsmap['pdf'])
    em_pdfaid = builder.ElementMaker(namespace=nsmap['pdfaid'])
    em_extension = builder.ElementMaker(namespace=nsmap['pdfaExtension'])
    em_schema = builder.ElementMaker(namespace=nsmap['pdfaSchema'])
    em_property = builder.ElementMaker(namespace=nsmap['pdfaProperty'])
    
    def generate_metadata(self) -> bytes:
        root = self.em_rdf.RDF(
            self._get_producer(),
            self._get_pdfa_details(),
            self._get_factur_x_values(),
            self._get_factur_x_schema(),
        )
        return etree.tostring(root)

    def _get_producer(self) -> etree.Element:
        return self.em_rdf.Description(
            self.em_pdf.Producer(f'WeasyPrint {weasyprint_version}'),
            {etree.QName(self.nsmap['rdf'], 'about'): ''},
        )

    def _get_pdfa_details(self) -> etree.Element:
        return self.em_rdf.Description(
            self.em_pdfaid.part('3'),
            self.em_pdfaid.conformance('B'),
            {etree.QName(self.nsmap['rdf'], 'about'): ''},
        )

    def _get_factur_x_values(self) -> etree.Element:
        return self.em_rdf.Description(
            self.em_fx.DocumentType('INVOICE'),
            self.em_fx.DocumentFileName('factur-x.xml'),
            self.em_fx.Version('1.0'),
            self.em_fx.ConformanceLevel('EN 16931'),
            {etree.QName(self.nsmap['rdf'], 'about'): ''},
        )

    def _get_factur_x_schema(self) -> etree.Element:
        return self.em_rdf.Description(
            self.em_extension.schemas(
                self.em_rdf.Bag(
                    self.em_rdf.li(
                        self.em_schema.schema('Factur-X PDFA Extension Schema'),
                        self.em_schema.namespaceURI('urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#'),
                        self.em_schema.prefix('fx'),
                        self.em_schema.property(
                            self.em_rdf.Seq(
                                self._get_schema_property(
                                    name='DocumentFileName',
                                    description='Name of the embedded XML invoice file',
                                ),
                                self._get_schema_property(
                                    name='DocumentType',
                                    description='INVOICE',
                                ),
                                self._get_schema_property(
                                    name='Version',
                                    description='The actual version of the Factur-X XML schema',
                                ),
                                self._get_schema_property(
                                    name='ConformanceLevel',
                                    description='The conformance level of the embedded Factur-X data',
                                ),
                            ),
                        ),
                        {etree.QName(self.nsmap['rdf'], 'parseType'): 'Resource'},
                    ),
                ),
            ),
            {etree.QName(self.nsmap['rdf'], 'about'): ''},
        )

    def _get_schema_property(self, name: str, description: str) -> etree.Element:
        return self.em_rdf.li(
            self.em_property.name(name),
            self.em_property.valueType('Text'),
            self.em_property.category('external'),
            self.em_property.description(description),
            {etree.QName(self.nsmap['rdf'], 'parseType'): 'Resource'},
        )

```

Will write a full examples in my blogpost, in a few days I will be able to provide more :)
liZe: > Just invited you to the repository.

Thank you!

> May I ask why you don't use typing? That's a bit uncommon / unexpected :)

Sure: https://github.com/Kozea/Pyphen/issues/50#issuecomment-1685404174

> Wanted to start writing the blog post as soon as I got a positive reaction from this MR, so I will start now. The method I would set at `rdf_metadata_generator()` would be `generate_metadata()` from this generator class:

Thanks a lot! I’ll take a look and see if it’s there’s anything I’d like to change.
the-infinity: About typing: this really helps IDEs like pyCharm, and it's actually the same reason: pyCharm has pretty good auto complete features and hints where you do something wrong if you provide proper typing. So I guess that's another way of being lazy: it's something the IDE can do a lot for you if you do the typing, and something where you don't have to think about that much any more. At python 3.5 / 3.6, I was not convinced, either, but starting with 3.8 and especially with 3.10, typing really makes sense - at least for for me :)

And, maybe an obvious question about the code: why didn't I add the whole Factur-X extension RDF to weasyprint? Well, two reasons:

1) Factur-X is not the only PDF/A extension which exists, these extensions are a generic framework, so adding just the Factur-X extension would be a very specific solution, preventing other people with other PDF/A extension needs to use weasyprint
2) I like lxml ElementBuilder a lot because the code is way cleaner then the code required by the standard library. But, adding lxml as a dependency to weasyprint felt wrong, because PDF/A extensions won't be used by most users, so for most users it will be an unused library.

the-infinity: Blog article is finished and published: https://binary-butterfly.de/artikel/factur-x-zugferd-e-invoices-with-python/ . Which parts do you think are interesting for the weasyprint docs, @liZe ? :)
the-infinity: > Thanks a lot for sharing. I think that I will adapt the code in "Use both XMLs to generate a PDF/A with attachment" as a small snippet, and add a link to your blog entry for more detailed information, if it’s OK for you.

Sounds good :)


> If PDF/A doesn’t allow the title in metadata, we may want to set that in WeasyPrint’s code. Do you remember where you saw this requirement?

Ok, that was an question where I had to look deeper. Turns out: that was not true, it was just a wrong assumption I had at some point based on an issue I saw ... somewhere. So, removed it.


> I think that the PDF identifier is automatically generated when you don’t provide one. Does it work for you?

A requirement is that if you regenerate the same invoice, it should give the same identifier. 

Patch:
diff --git a/docs/common_use_cases.rst b/docs/common_use_cases.rst
index 8d506826..59962b93 100644
--- a/docs/common_use_cases.rst
+++ b/docs/common_use_cases.rst
@@ -102,8 +102,8 @@ such as page numbers, headers, etc. Read more about the page_ at-rule.
 .. _page: https://developer.mozilla.org/en-US/docs/Web/CSS/@page
 
 
-Generate PDFs Specialized for Accessibility (PDF/UA) and Archiving (PDF/A)
---------------------------------------------------------------------------
+Generate Specialized PDFs
+-------------------------
 
 WeasyPrint can generate different PDF variants, including PDF/UA and PDF/A. The
 feature is available by using the ``--pdf-variant`` CLI option, or the
@@ -125,8 +125,8 @@ Even if WeasyPrint tries to generate valid documents, the result is not
 guaranteed: the HTML, CSS and PDF features chosen by the user must follow the
 limitations defined by the different specifications.
 
-PDF/A
-.....
+PDF/A (Archiving)
+.................
 
 PDF/A documents are specialized for archiving purposes. They are a simple
 subset of PDF, with a lot of limitations: no audio, video or JavaScript,
@@ -145,8 +145,8 @@ valid PDF identifier, but you can provide your own with the
 If your document includes images, you must set the ``image-rendering:
 crisp-edges`` property to avoid anti-aliasing, that is forbidden by PDF/A.
 
-PDF/UA
-......
+PDF/UA (Universal Accessibility)
+................................
 
 PDF/UA documents are specialized for accessibility purposes. They include extra
 metadata that define document information and content structure.
@@ -158,6 +158,179 @@ also used to define the order of the PDF content.
 Some information is required in your HTML file, including a ``<title>`` tag,
 and a ``lang`` attribute set on the ``<html>`` tag.
 
+Factur-X / ZUGFeRD (Electronic Invoices)
+........................................
+
+Factur-X / ZUGFeRD is a Franco-German standard for hybrid e-invoice, the first
+implementation of the European Semantic Standard EN 16931. It enables users to
+include normalized metadata in PDF invoices, such as companies information or
+invoice amounts, so that compatible software can automatically read this
+information. This standard is based on PDF/A-3b.
+
+WeasyPrint can generate Factur-X / ZUGFeRD documents. Invoice metadata must be
+generated by the user and included in the PDF document when rendered. Two
+different metadata files are required:
+
+- the first one is RDF metadata, containing document metadata and PDF/A
+  extension information;
+- the second one is Factur-X / ZUGFeRD metadata, containing invoice amounts,
+  plus seller and buyer information.
+
+Here is an example of Factur-X document generation.
+
+``rdf.xml``:
+
+.. code-block:: xml
+
+  <x:xmpmeta
+      xmlns:x="adobe:ns:meta/"
+      xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+      xmlns:pdf="http://ns.adobe.com/pdf/1.3/"
+      xmlns:fx="urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#"
+      xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/"
+      xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#"
+      xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#">
+    <!-- placeholder -->
+    <rdf:RDF>
+      <rdf:Description rdf:about="">
+        <fx:ConformanceLevel>MINIMUM</fx:ConformanceLevel>
+        <fx:DocumentFileName>factur-x.xml</fx:DocumentFileName>
+        <fx:DocumentType>INVOICE</fx:DocumentType>
+        <fx:Version>1.0</fx:Version>
+      </rdf:Description>
+      <rdf:Description rdf:about="">
+        <pdfaExtension:schemas>
+          <rdf:Bag>
+            <rdf:li rdf:parseType="Resource">
+              <pdfaSchema:schema>Factur-X PDFA Extension Schema</pdfaSchema:schema>
+              <pdfaSchema:namespaceURI>urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#</pdfaSchema:namespaceURI>
+              <pdfaSchema:prefix>fx</pdfaSchema:prefix>
+              <pdfaSchema:property>
+                <rdf:Seq>
+                  <rdf:li rdf:parseType="Resource">
+                    <pdfaProperty:name>DocumentFileName</pdfaProperty:name>
+                    <pdfaProperty:valueType>Text</pdfaProperty:valueType>
+                    <pdfaProperty:category>external</pdfaProperty:category>
+                    <pdfaProperty:description>name of the embedded XML invoice file</pdfaProperty:description>
+                  </rdf:li>
+                  <rdf:li rdf:parseType="Resource">
+                    <pdfaProperty:name>DocumentType</pdfaProperty:name>
+                    <pdfaProperty:valueType>Text</pdfaProperty:valueType>
+                    <pdfaProperty:category>external</pdfaProperty:category>
+                    <pdfaProperty:description>INVOICE</pdfaProperty:description>
+                  </rdf:li>
+                  <rdf:li rdf:parseType="Resource">
+                    <pdfaProperty:name>Version</pdfaProperty:name>
+                    <pdfaProperty:valueType>Text</pdfaProperty:valueType>
+                    <pdfaProperty:category>external</pdfaProperty:category>
+                    <pdfaProperty:description>The actual version of the Factur-X XML schema</pdfaProperty:description>
+                  </rdf:li>
+                  <rdf:li rdf:parseType="Resource">
+                    <pdfaProperty:name>ConformanceLevel</pdfaProperty:name>
+                    <pdfaProperty:valueType>Text</pdfaProperty:valueType>
+                    <pdfaProperty:category>external</pdfaProperty:category>
+                    <pdfaProperty:description>The conformance level of the embedded Factur-X data</pdfaProperty:description>
+                  </rdf:li>
+                </rdf:Seq>
+              </pdfaSchema:property>
+            </rdf:li>
+          </rdf:Bag>
+        </pdfaExtension:schemas>
+      </rdf:Description>
+    </rdf:RDF>
+  </x:xmpmeta>
+
+``factur-x.xml``:
+
+.. code-block:: xml
+
+  <rsm:CrossIndustryInvoice
+      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+      xmlns:qdt="urn:un:unece:uncefact:data:standard:QualifiedDataType:100"
+      xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100"
+      xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
+      xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100">
+    <rsm:ExchangedDocumentContext>
+      <ram:BusinessProcessSpecifiedDocumentContextParameter>
+        <ram:ID>A1</ram:ID>
+      </ram:BusinessProcessSpecifiedDocumentContextParameter>
+      <ram:GuidelineSpecifiedDocumentContextParameter>
+        <ram:ID>urn:factur-x.eu:1p0:minimum</ram:ID>
+      </ram:GuidelineSpecifiedDocumentContextParameter>
+    </rsm:ExchangedDocumentContext>
+    <rsm:ExchangedDocument>
+      <ram:ID>123</ram:ID>
+      <ram:TypeCode>380</ram:TypeCode>
+      <ram:IssueDateTime>
+        <udt:DateTimeString format="102">20200131</udt:DateTimeString>
+      </ram:IssueDateTime>
+    </rsm:ExchangedDocument>
+    <rsm:SupplyChainTradeTransaction>
+      <ram:ApplicableHeaderTradeAgreement>
+        <ram:BuyerReference>Buyer</ram:BuyerReference>
+        <ram:SellerTradeParty>
+          <ram:Name>Supplyer Corp</ram:Name>
+          <ram:SpecifiedLegalOrganization>
+            <ram:ID schemeID="0002">123456782</ram:ID>
+          </ram:SpecifiedLegalOrganization>
+          <ram:PostalTradeAddress>
+            <ram:CountryID>FR</ram:CountryID>
+          </ram:PostalTradeAddress>
+          <ram:SpecifiedTaxRegistration>
+            <ram:ID schemeID="VA">FR11123456782</ram:ID>
+          </ram:SpecifiedTaxRegistration>
+        </ram:SellerTradeParty>
+        <ram:BuyerTradeParty>
+          <ram:Name>Buyer Corp</ram:Name>
+          <ram:SpecifiedLegalOrganization>
+            <ram:ID schemeID="0002">987654324</ram:ID>
+          </ram:SpecifiedLegalOrganization>
+        </ram:BuyerTradeParty>
+        <ram:BuyerOrderReferencedDocument >
+          <ram:IssuerAssignedID>456</ram:IssuerAssignedID>
+        </ram:BuyerOrderReferencedDocument>
+      </ram:ApplicableHeaderTradeAgreement>
+      <ram:ApplicableHeaderTradeDelivery/>
+      <ram:ApplicableHeaderTradeSettlement>
+        <ram:InvoiceCurrencyCode>EUR</ram:InvoiceCurrencyCode>
+        <ram:SpecifiedTradeSettlementHeaderMonetarySummation>
+          <ram:TaxBasisTotalAmount>100.00</ram:TaxBasisTotalAmount>
+          <ram:TaxTotalAmount currencyID="EUR">20.00</ram:TaxTotalAmount>
+          <ram:GrandTotalAmount>120.00</ram:GrandTotalAmount>
+          <ram:DuePayableAmount>120.00</ram:DuePayableAmount>
+        </ram:SpecifiedTradeSettlementHeaderMonetarySummation>
+      </ram:ApplicableHeaderTradeSettlement>
+    </rsm:SupplyChainTradeTransaction>
+  </rsm:CrossIndustryInvoice>
+
+``invoice.py``:
+
+.. code-block:: python
+
+  from pathlib import Path
+  from weasyprint import Attachment, HTML
+
+  def generate_rdf_metadata(metadata, variant, version, conformance):
+      original_rdf = generate_original_rdf_metadata(metadata, variant, version, conformance)
+      return Path("rdf.xml").read_bytes().replace(b"<!-- placeholder -->", original_rdf)
+
+  document = HTML(string="<h1>Invoice</h1>").render()
+  generate_original_rdf_metadata = document.metadata.generate_rdf_metadata
+
+  factur_x_xml = Path("factur-x.xml").read_text()
+  attachment = Attachment(string=factur_x_xml, name="factur-x.xml", relationship="Data")
+  document.metadata.attachments = [attachment]
+
+  document.metadata.generate_rdf_metadata = generate_rdf_metadata
+  document.write_pdf("invoice.pdf", pdf_variant="pdf/a-3b")
+
+Of course, the content of these files has to be adapted to the content of real
+invoices. Using XML generators instead of plain text manipulation is also
+highly recommended.
+
+A more detailed blog article is available on `Binary Butterfly’s website
+<https://binary-butterfly.de/artikel/factur-x-zugferd-e-invoices-with-python/>`_.
+
 
 Include PDF Forms
 -----------------
diff --git a/weasyprint/__init__.py b/weasyprint/__init__.py
index 40635bf0..a3acac43 100644
--- a/weasyprint/__init__.py
+++ b/weasyprint/__init__.py
@@ -318,6 +318,9 @@ class Attachment:
     HTML specific arguments (``encoding`` and ``media_type``) are not
     supported.
 
+    :param str name:
+        The name of the attachment to be included in the PDF document.
+        May be :obj:`None`.
     :param str description:
         A description of the attachment to be included in the PDF document.
         May be :obj:`None`.
@@ -335,11 +338,12 @@ class Attachment:
     """
     def __init__(self, guess=None, filename=None, url=None, file_obj=None,
                  string=None, base_url=None, url_fetcher=default_url_fetcher,
-                 description=None, created=None, modified=None,
+                 name=None, description=None, created=None, modified=None,
                  relationship='Unspecified'):
         self.source = _select_source(
             guess, filename, url, file_obj, string, base_url=base_url,
             url_fetcher=url_fetcher)
+        self.name = name
         self.description = description
         self.relationship = relationship
         self.md5 = None
diff --git a/weasyprint/document.py b/weasyprint/document.py
index f630fe6a..235cf2c1 100644
--- a/weasyprint/document.py
+++ b/weasyprint/document.py
@@ -18,6 +18,7 @@ from .layout import LayoutContext, layout_document
 from .logger import PROGRESS_LOGGER
 from .matrix import Matrix
 from .pdf import VARIANTS, generate_pdf
+from .pdf.metadata import generate_rdf_metadata
 from .text.fonts import FontConfiguration
 
 
@@ -105,12 +106,10 @@ class DocumentMetadata:
     """Meta-information belonging to a whole :class:`Document`.
 
     New attributes may be added in future versions of WeasyPrint.
-
     """
-
-    def __init__(self, title=None, authors=None, description=None,
-                 keywords=None, generator=None, created=None, modified=None,
-                 attachments=None, lang=None, custom=None):
+    def __init__(self, title=None, authors= None, description=None, keywords=None,
+                 generator=None, created=None, modified=None, attachments=None,
+                 lang=None, custom=None, generate_rdf_metadata=generate_rdf_metadata):
         #: The title of the document, as a string or :obj:`None`.
         #: Extracted from the ``<title>`` element in HTML
         #: and written to the ``/Title`` info field in PDF.
@@ -156,6 +155,9 @@ class DocumentMetadata:
         #: Custom metadata, as a dict whose keys are the metadata names and
         #: values are the metadata values.
         self.custom = custom or {}
+        #: Custom RDF metadata generator, which will replace the default generator.
+        #: The function should return bytes containing an RDF XML.
+        self.generate_rdf_metadata = generate_rdf_metadata
 
 
 class DiskCache:
diff --git a/weasyprint/pdf/__init__.py b/weasyprint/pdf/__init__.py
index 27dce58c..65caf72d 100644
--- a/weasyprint/pdf/__init__.py
+++ b/weasyprint/pdf/__init__.py
@@ -271,7 +271,7 @@ def generate_pdf(document, target, zoom, **options):
     if pdf_attachments:
         content = pydyf.Dictionary({'Names': pydyf.Array()})
         for i, pdf_attachment in enumerate(pdf_attachments):
-            content['Names'].append(pydyf.String(f'attachment{i}'))
+            content['Names'].append(pdf_attachment['F'])
             content['Names'].append(pdf_attachment.reference)
         pdf.add_object(content)
         if 'Names' not in pdf.catalog:
diff --git a/weasyprint/pdf/anchors.py b/weasyprint/pdf/anchors.py
index ebf489cd..2c3858ac 100644
--- a/weasyprint/pdf/anchors.py
+++ b/weasyprint/pdf/anchors.py
@@ -351,7 +351,9 @@ def write_pdf_attachment(pdf, attachment, compress):
 
     # TODO: Use the result object from a URL fetch operation to provide more
     # details on the possible filename and MIME type.
-    if url and urlsplit(url).path:
+    if attachment.name:
+        filename = attachment.name
+    elif url and urlsplit(url).path:
         filename = basename(unquote(urlsplit(url).path))
     else:
         filename = 'attachment.bin'
@@ -376,7 +378,7 @@ def write_pdf_attachment(pdf, attachment, compress):
 
     pdf_attachment = pydyf.Dictionary({
         'Type': '/Filespec',
-        'F': pydyf.String(),
+        'F': pydyf.String(filename.encode(errors='ignore')),
         'UF': pydyf.String(filename),
         'EF': pydyf.Dictionary({'F': file_stream.reference}),
         'Desc': pydyf.String(attachment.description or ''),
diff --git a/weasyprint/pdf/metadata.py b/weasyprint/pdf/metadata.py
index 7cde641f..12ce4528 100644
--- a/weasyprint/pdf/metadata.py
+++ b/weasyprint/pdf/metadata.py
@@ -25,7 +25,22 @@ def add_metadata(pdf, metadata, variant, version, conformance, compress):
     Described in ISO-32000-1:2008, 14.3.2.
 
     """
-    # Add metadata
+    header = b'<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>'
+    footer = b'<?xpacket end="r"?>'
+    xml_data = metadata.generate_rdf_metadata(metadata, variant, version, conformance)
+    stream_content = b'\n'.join((header, xml_data, footer))
+    extra = {'Type': '/Metadata', 'Subtype': '/XML'}
+    metadata = pydyf.Stream([stream_content], extra, compress)
+    pdf.add_object(metadata)
+    pdf.catalog['Metadata'] = metadata.reference
+
+
+def generate_rdf_metadata(metadata, variant, version, conformance):
+    """Generate RDF metadata as a bytestring.
+
+    Might be replaced by DocumentMetadata.rdf_metadata_generator().
+
+    """
     namespace = f'pdf{variant}id'
     rdf = Element(f'{{{NS["rdf"]}}}RDF')
 
@@ -82,11 +97,4 @@ def add_metadata(pdf, metadata, variant, version, conformance, compress):
         element.attrib[f'{{{NS["rdf"]}}}about'] = ''
         element = SubElement(element, f'{{{NS["xmp"]}}}ModifyDate')
         element.text = metadata.modified
-    xml = tostring(rdf, encoding='utf-8')
-    header = b'<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>'
-    footer = b'<?xpacket end="r"?>'
-    stream_content = b'\n'.join((header, xml, footer))
-    extra = {'Type': '/Metadata', 'Subtype': '/XML'}
-    metadata = pydyf.Stream([stream_content], extra, compress)
-    pdf.add_object(metadata)
-    pdf.catalog['Metadata'] = metadata.reference
+    return tostring(rdf, encoding='utf-8')


Test patch:
diff --git a/tests/test_api.py b/tests/test_api.py
index 48b3efe6..5ee0644b 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -18,6 +18,7 @@ from PIL import Image
 
 from weasyprint import CSS, HTML, __main__, default_url_fetcher
 from weasyprint.pdf.anchors import resolve_links
+from weasyprint.pdf.metadata import generate_rdf_metadata
 from weasyprint.urls import path2url
 
 from .draw import parse_pixels
@@ -414,14 +415,14 @@ def test_command_line_render(tmp_path):
         os.environ.pop('SOURCE_DATE_EPOCH')
 
         stdout = _run('combined.html --uncompressed-pdf -')
-        assert stdout.count(b'attachment') == 0
+        assert stdout.count(b'Filespec') == 0
         stdout = _run('combined.html --uncompressed-pdf -')
-        assert stdout.count(b'attachment') == 0
+        assert stdout.count(b'Filespec') == 0
         stdout = _run('-a pattern.png --uncompressed-pdf combined.html -')
-        assert stdout.count(b'attachment') == 1
+        assert stdout.count(b'Filespec') == 1
         stdout = _run(
             '-a style.css -a pattern.png --uncompressed-pdf combined.html -')
-        assert stdout.count(b'attachment') == 2
+        assert stdout.count(b'Filespec') == 2
 
         _run('combined.html out23.pdf --timeout 30')
         assert (tmp_path / 'out23.pdf').read_bytes() == pdf_bytes
@@ -1140,6 +1141,7 @@ def assert_meta(html, **meta):
     meta.setdefault('attachments', [])
     meta.setdefault('lang', None)
     meta.setdefault('custom', {})
+    meta.setdefault('generate_rdf_metadata', generate_rdf_metadata)
     assert vars(FakeHTML(string=html).render().metadata) == meta
 
 
diff --git a/tests/test_pdf.py b/tests/test_pdf.py
index d657e6d9..ee24535d 100644
--- a/tests/test_pdf.py
+++ b/tests/test_pdf.py
@@ -598,7 +598,7 @@ def test_embedded_files_attachments(tmp_path):
         ]
     )
     assert f'<{hashlib.md5(b"hi there").hexdigest()}>'.encode() in pdf
-    assert b'/F ()' in pdf
+    assert b'/F (attachment.bin)' in pdf
     assert b'/UF (attachment.bin)' in pdf
     name = BOM_UTF16_BE + 'some file attachment äöü'.encode('utf-16-be')
     assert b'/Desc <' + name.hex().encode() + b'>' in pdf
@@ -716,3 +716,29 @@ def test_bleed(style, media, bleed, trim):
     assert f'/MediaBox {str(media).replace(",", "")}'.encode() in pdf
     assert f'/BleedBox {str(bleed).replace(",", "")}'.encode() in pdf
     assert f'/TrimBox {str(trim).replace(",", "")}'.encode() in pdf
+
+
+@assert_no_logs
+def test_default_rdf_metadata():
+    pdf_document = FakeHTML(string='<body>test</body>').render()
+
+    pdf_document.metadata.title = None
+
+    pdf_bytes = pdf_document.write_pdf(
+        pdf_variant='pdf/a-3b', pdf_identifier=b'example-bytes', uncompressed_pdf=True)
+    assert b'<rdf:RDF xmlns:pdf="http://ns.adobe.com/pdf/1.3/"' in pdf_bytes
+
+
+@assert_no_logs
+def test_custom_rdf_metadata():
+    def generate_rdf_metadata(*args, **kwargs):
+        return b'TEST_METADATA'
+
+    pdf_document = FakeHTML(string='<body>test</body>').render()
+
+    pdf_document.metadata.title = None
+    pdf_document.metadata.generate_rdf_metadata = generate_rdf_metadata
+
+    pdf_bytes = pdf_document.write_pdf(
+        pdf_variant='pdf/a-3b', pdf_identifier=b'example-bytes', uncompressed_pdf=True)
+    assert b'TEST_METADATA' in pdf_bytes