diff --git a/src/core/layout/qgslayoutexporter.cpp b/src/core/layout/qgslayoutexporter.cpp index 50335b01dbe3..05b3b64cf1f2 100644 --- a/src/core/layout/qgslayoutexporter.cpp +++ b/src/core/layout/qgslayoutexporter.cpp @@ -26,6 +26,7 @@ #include "qgslayoutgeopdfexporter.h" #include "qgslinestring.h" #include "qgsmessagelog.h" +#include "qgsprojectstylesettings.h" #include "qgslabelingresults.h" #include "qgssettingsentryimpl.h" #include "qgssettingstree.h" @@ -36,6 +37,11 @@ #include #include #include +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) +#include +#include +#endif +#include #include "gdal.h" #include "cpl_conv.h" @@ -650,8 +656,8 @@ QgsLayoutExporter::ExportResult QgsLayoutExporter::exportToPdf( const QString &f { // copy layout metadata to GeoPDF export settings details.author = mLayout->project()->metadata().author(); - details.producer = QStringLiteral( "QGIS %1" ).arg( Qgis::version() ); - details.creator = QStringLiteral( "QGIS %1" ).arg( Qgis::version() ); + details.producer = getCreator(); + details.creator = getCreator(); details.creationDateTime = mLayout->project()->metadata().creationDateTime(); details.subject = mLayout->project()->metadata().abstract(); details.title = mLayout->project()->metadata().title(); @@ -1264,7 +1270,7 @@ QMap QgsLayoutExporter::takeLabelingResults() return res; } -void QgsLayoutExporter::preparePrintAsPdf( QgsLayout *layout, QPagedPaintDevice *device, const QString &filePath ) +void QgsLayoutExporter::preparePrintAsPdf( QgsLayout *layout, QPdfWriter *device, const QString &filePath ) { QFileInfo fi( filePath ); QDir dir; @@ -1275,6 +1281,48 @@ void QgsLayoutExporter::preparePrintAsPdf( QgsLayout *layout, QPagedPaintDevice updatePrinterPageSize( layout, device, firstPageToBeExported( layout ) ); + // force a non empty title to avoid invalid (according to specification) PDF/X-4 + const QString title = !layout->project() || layout->project()->metadata().title().isEmpty() ? + fi.baseName() : layout->project()->metadata().title(); + + device->setTitle( title ); + + QPagedPaintDevice::PdfVersion pdfVersion = QPagedPaintDevice::PdfVersion_1_4; + +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + + if ( const QgsProjectStyleSettings *styleSettings = ( layout->project() ? layout->project()->styleSettings() : nullptr ) ) + { + // We don't want to let AUTO color model because we could end up writing RGB colors with a CMYK + // output intent color model and vice versa, so we force color conversion + switch ( styleSettings->colorModel() ) + { + case Qgis::ColorModel::Cmyk: + device->setColorModel( QPdfWriter::ColorModel::CMYK ); + break; + + case Qgis::ColorModel::Rgb: + device->setColorModel( QPdfWriter::ColorModel::RGB ); + break; + } + + const QColorSpace colorSpace = styleSettings->colorSpace(); + if ( colorSpace.isValid() ) + { + QPdfOutputIntent outputIntent; + outputIntent.setOutputProfile( colorSpace ); + device->setOutputIntent( outputIntent ); + + // PDF/X-4 standard allows PDF to be printing ready and is only possible if a color space has been set + pdfVersion = QPagedPaintDevice::PdfVersion_X4; + } + } + +#endif + + device->setPdfVersion( pdfVersion ); + setXmpMetadata( device, layout ); + // TODO: add option for this in layout // May not work on Windows or non-X11 Linux. Works fine on Mac using QPrinter::NativeFormat //printer.setFontEmbeddingEnabled( true ); @@ -1282,7 +1330,7 @@ void QgsLayoutExporter::preparePrintAsPdf( QgsLayout *layout, QPagedPaintDevice #if defined(HAS_KDE_QT5_PDF_TRANSFORM_FIX) || QT_VERSION >= QT_VERSION_CHECK(6, 3, 0) // paint engine hack not required, fixed upstream #else - QgsPaintEngineHack::fixEngineFlags( device->paintEngine() ); + QgsPaintEngineHack::fixEngineFlags( static_cast( device )->paintEngine() ); #endif } @@ -1529,7 +1577,7 @@ void QgsLayoutExporter::appendMetadataToSvg( QDomDocument &svg ) const }; addAgentNode( QStringLiteral( "dc:creator" ), metadata.author() ); - addAgentNode( QStringLiteral( "dc:publisher" ), QStringLiteral( "QGIS %1" ).arg( Qgis::version() ) ); + addAgentNode( QStringLiteral( "dc:publisher" ), getCreator() ); // keywords { @@ -1714,7 +1762,7 @@ bool QgsLayoutExporter::georeferenceOutputPrivate( const QString &file, QgsLayou GDALSetMetadataItem( outputDS.get(), "CREATION_DATE", creationDateString.toUtf8().constData(), nullptr ); GDALSetMetadataItem( outputDS.get(), "AUTHOR", mLayout->project()->metadata().author().toUtf8().constData(), nullptr ); - const QString creator = QStringLiteral( "QGIS %1" ).arg( Qgis::version() ); + const QString creator = getCreator(); GDALSetMetadataItem( outputDS.get(), "CREATOR", creator.toUtf8().constData(), nullptr ); GDALSetMetadataItem( outputDS.get(), "PRODUCER", creator.toUtf8().constData(), nullptr ); GDALSetMetadataItem( outputDS.get(), "SUBJECT", mLayout->project()->metadata().abstract().toUtf8().constData(), nullptr ); @@ -2172,7 +2220,7 @@ bool QgsLayoutExporter::saveImage( const QImage &image, const QString &imageFile if ( projectForMetadata ) { w.setText( QStringLiteral( "Author" ), projectForMetadata->metadata().author() ); - const QString creator = QStringLiteral( "QGIS %1" ).arg( Qgis::version() ); + const QString creator = getCreator(); w.setText( QStringLiteral( "Creator" ), creator ); w.setText( QStringLiteral( "Producer" ), creator ); w.setText( QStringLiteral( "Subject" ), projectForMetadata->metadata().abstract() ); @@ -2190,3 +2238,129 @@ bool QgsLayoutExporter::saveImage( const QImage &image, const QString &imageFile } return w.write( image ); } + +QString QgsLayoutExporter::getCreator() +{ + return QStringLiteral( "QGIS %1" ).arg( Qgis::version() ); +} + +void QgsLayoutExporter::setXmpMetadata( QPdfWriter *pdfWriter, QgsLayout *layout ) +{ +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + QUuid documentId = pdfWriter->documentId(); +#else + QUuid documentId = QUuid::createUuid(); +#endif + + // XMP metadata date format differs from PDF dictionary one + const QDateTime creationDateTime = layout->project() ? layout->project()->metadata().creationDateTime() : QDateTime(); + const QString metaDataDate = creationDateTime.isValid() ? creationDateTime.toOffsetFromUtc( creationDateTime.offsetFromUtc() ).toString( Qt::ISODate ) : QString(); + const QString title = pdfWriter->title(); + const QString creator = getCreator(); + const QString producer = creator; + const QString author = layout->project() ? layout->project()->metadata().author() : QString(); + + // heavily inspired from qpdf.cpp QPdfEnginePrivate::writeXmpDocumentMetaData + + const QLatin1String xmlNS( "http://www.w3.org/XML/1998/namespace" ); + const QLatin1String adobeNS( "adobe:ns:meta/" ); + const QLatin1String rdfNS( "http://www.w3.org/1999/02/22-rdf-syntax-ns#" ); + const QLatin1String dcNS( "http://purl.org/dc/elements/1.1/" ); + const QLatin1String xmpNS( "http://ns.adobe.com/xap/1.0/" ); + const QLatin1String xmpMMNS( "http://ns.adobe.com/xap/1.0/mm/" ); + const QLatin1String pdfNS( "http://ns.adobe.com/pdf/1.3/" ); + const QLatin1String pdfaidNS( "http://www.aiim.org/pdfa/ns/id/" ); + const QLatin1String pdfxidNS( "http://www.npes.org/pdfx/ns/id/" ); + + QByteArray xmpMetadata; + QBuffer output( &xmpMetadata ); + output.open( QIODevice::WriteOnly ); + output.write( "" ); + + QXmlStreamWriter w( &output ); + w.setAutoFormatting( true ); + w.writeNamespace( adobeNS, "x" ); //#spellok + w.writeNamespace( rdfNS, "rdf" ); //#spellok + w.writeNamespace( dcNS, "dc" ); //#spellok + w.writeNamespace( xmpNS, "xmp" ); //#spellok + w.writeNamespace( xmpMMNS, "xmpMM" ); //#spellok + w.writeNamespace( pdfNS, "pdf" ); //#spellok + w.writeNamespace( pdfaidNS, "pdfaid" ); //#spellok + w.writeNamespace( pdfxidNS, "pdfxid" ); //#spellok + + w.writeStartElement( adobeNS, "xmpmeta" ); + w.writeStartElement( rdfNS, "RDF" ); + + // DC + w.writeStartElement( rdfNS, "Description" ); + w.writeAttribute( rdfNS, "about", "" ); + w.writeStartElement( dcNS, "title" ); + w.writeStartElement( rdfNS, "Alt" ); + w.writeStartElement( rdfNS, "li" ); + w.writeAttribute( xmlNS, "lang", "x-default" ); + w.writeCharacters( title ); + w.writeEndElement(); + w.writeEndElement(); + w.writeEndElement(); + + w.writeStartElement( dcNS, "creator" ); + w.writeStartElement( rdfNS, "Seq" ); + w.writeStartElement( rdfNS, "li" ); + w.writeCharacters( author ); + w.writeEndElement(); + w.writeEndElement(); + w.writeEndElement(); + + w.writeEndElement(); + + // PDF + w.writeStartElement( rdfNS, "Description" ); + w.writeAttribute( rdfNS, "about", "" ); + w.writeAttribute( pdfNS, "Producer", producer ); + w.writeAttribute( pdfNS, "Trapped", "False" ); + w.writeEndElement(); + + // XMP + w.writeStartElement( rdfNS, "Description" ); + w.writeAttribute( rdfNS, "about", "" ); + w.writeAttribute( xmpNS, "CreatorTool", creator ); + w.writeAttribute( xmpNS, "CreateDate", metaDataDate ); + w.writeAttribute( xmpNS, "ModifyDate", metaDataDate ); + w.writeAttribute( xmpNS, "MetadataDate", metaDataDate ); + w.writeEndElement(); + + // XMPMM + w.writeStartElement( rdfNS, "Description" ); + w.writeAttribute( rdfNS, "about", "" ); + w.writeAttribute( xmpMMNS, "DocumentID", "uuid:" + documentId.toString( QUuid::WithoutBraces ) ); + w.writeAttribute( xmpMMNS, "VersionID", "1" ); + w.writeAttribute( xmpMMNS, "RenditionClass", "default" ); + w.writeEndElement(); + +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + + // Version-specific + switch ( pdfWriter->pdfVersion() ) + { + case QPagedPaintDevice::PdfVersion_1_4: + case QPagedPaintDevice::PdfVersion_A1b: // A1b and 1.6 are not used by QGIS + case QPagedPaintDevice::PdfVersion_1_6: + break; + case QPagedPaintDevice::PdfVersion_X4: + w.writeStartElement( rdfNS, "Description" ); + w.writeAttribute( rdfNS, "about", "" ); + w.writeAttribute( pdfxidNS, "GTS_PDFXVersion", "PDF/X-4" ); + w.writeEndElement(); + break; + } + +#endif + + w.writeEndElement(); // + w.writeEndElement(); // + + w.writeEndDocument(); + output.write( "" ); + + pdfWriter->setDocumentXmpMetadata( xmpMetadata ); +} diff --git a/src/core/layout/qgslayoutexporter.h b/src/core/layout/qgslayoutexporter.h index c5fb3018f749..834a6a18e956 100644 --- a/src/core/layout/qgslayoutexporter.h +++ b/src/core/layout/qgslayoutexporter.h @@ -742,7 +742,7 @@ class CORE_EXPORT QgsLayoutExporter /** * Prepare a \a device for printing a layout as a PDF, to the destination \a filePath. */ - static void preparePrintAsPdf( QgsLayout *layout, QPagedPaintDevice *device, const QString &filePath ); + static void preparePrintAsPdf( QgsLayout *layout, QPdfWriter *device, const QString &filePath ); static void preparePrint( QgsLayout *layout, QPagedPaintDevice *device, bool setFirstPageSize = false ); @@ -777,6 +777,12 @@ class CORE_EXPORT QgsLayoutExporter const std::function &getItemExportGroupFunc ); + // Returns PDF creator (used also as producer) + static QString getCreator(); + + // Set PDF XMP metadata on pdfWriter for given layout + static void setXmpMetadata( QPdfWriter *pdfWriter, QgsLayout *layout ); + static QgsVectorSimplifyMethod createExportSimplifyMethod(); static QgsMaskRenderSettings createExportMaskSettings(); friend class TestQgsLayout; diff --git a/tests/src/python/test_qgslayoutexporter.py b/tests/src/python/test_qgslayoutexporter.py index 04114680aaf6..a316bba7db12 100644 --- a/tests/src/python/test_qgslayoutexporter.py +++ b/tests/src/python/test_qgslayoutexporter.py @@ -12,6 +12,10 @@ import os import subprocess import tempfile +import xml.etree.ElementTree as etree +from uuid import UUID +from io import StringIO + from typing import Optional from osgeo import gdal @@ -29,6 +33,7 @@ from qgis.PyQt.QtPrintSupport import QPrinter from qgis.PyQt.QtSvg import QSvgRenderer from qgis.core import ( + Qgis, QgsCoordinateReferenceSystem, QgsFeature, QgsFillSymbol, @@ -445,8 +450,11 @@ def testExportToImage(self): def testExportToPdf(self): md = QgsProject.instance().metadata() - md.setTitle('proj title') - md.setAuthor('proj author') + + projTitle = 'proj titlea /<é' + projAuthor = 'proj author /<é' + md.setTitle(projTitle) + md.setAuthor(projAuthor) md.setCreationDateTime(QDateTime(QDate(2011, 5, 3), QTime(9, 4, 5), QTimeZone(36000))) md.setIdentifier('proj identifier') md.setAbstract('proj abstract') @@ -518,11 +526,62 @@ def testExportToPdf(self): d = gdal.Open(pdf_file_path) metadata = d.GetMetadata() - self.assertEqual(metadata['AUTHOR'], 'proj author') + self.assertEqual(metadata['AUTHOR'], projAuthor) self.assertEqual(metadata['CREATION_DATE'], "D:20110503090405+10'0'") self.assertEqual(metadata['KEYWORDS'], 'KWx: kw3,kw4;kw: kw1,kw2') self.assertEqual(metadata['SUBJECT'], 'proj abstract') - self.assertEqual(metadata['TITLE'], 'proj title') + self.assertEqual(metadata['TITLE'], projTitle) + qgisId = f"QGIS {Qgis.version()}" + self.assertEqual(metadata['CREATOR'], qgisId) + + # check XMP metadata + xmpMetadata = d.GetMetadata("xml:XMP") + self.assertEqual(len(xmpMetadata), 1) + xmp = xmpMetadata[0] + self.assertTrue(xmp) + xmpDoc = etree.fromstring(xmp) + namespaces = dict([node for _, node in etree.iterparse(StringIO(xmp), events=['start-ns'])]) + + title = xmpDoc.findall("rdf:RDF/rdf:Description/dc:title/rdf:Alt/rdf:li", namespaces) + self.assertEqual(len(title), 1) + self.assertEqual(title[0].text, projTitle) + + creator = xmpDoc.findall("rdf:RDF/rdf:Description/dc:creator/rdf:Seq/rdf:li", namespaces) + self.assertEqual(len(creator), 1) + self.assertEqual(creator[0].text, projAuthor) + + producer = xmpDoc.findall("rdf:RDF/rdf:Description[@pdf:Producer]", namespaces) + self.assertEqual(len(producer), 1) + self.assertEqual(producer[0].attrib["{" + namespaces["pdf"] + "}" + "Producer"], qgisId) + + producer2 = xmpDoc.findall("rdf:RDF/rdf:Description[@xmp:CreatorTool]", namespaces) + self.assertEqual(len(producer2), 1) + self.assertEqual(producer2[0].attrib["{" + namespaces["xmp"] + "}" + "CreatorTool"], qgisId) + + creationDateTags = xmpDoc.findall("rdf:RDF/rdf:Description[@xmp:CreateDate]", namespaces) + self.assertEqual(len(creationDateTags), 1) + creationDate = creationDateTags[0].attrib["{" + namespaces["xmp"] + "}" + "CreateDate"] + self.assertEqual(creationDate, "2011-05-03T09:04:05+10:00") + + metadataDateTags = xmpDoc.findall("rdf:RDF/rdf:Description[@xmp:MetadataDate]", namespaces) + self.assertEqual(len(metadataDateTags), 1) + metadataDate = metadataDateTags[0].attrib["{" + namespaces["xmp"] + "}" + "MetadataDate"] + self.assertEqual(metadataDate, "2011-05-03T09:04:05+10:00") + + modifyDateTags = xmpDoc.findall("rdf:RDF/rdf:Description[@xmp:ModifyDate]", namespaces) + self.assertEqual(len(modifyDateTags), 1) + modifyDate = modifyDateTags[0].attrib["{" + namespaces["xmp"] + "}" + "ModifyDate"] + self.assertEqual(modifyDate, "2011-05-03T09:04:05+10:00") + + docIdTags = xmpDoc.findall("rdf:RDF/rdf:Description[@xmpMM:DocumentID]", namespaces) + self.assertEqual(len(docIdTags), 1) + docId = docIdTags[0].attrib["{" + namespaces["xmpMM"] + "}" + "DocumentID"] + uuidValid = True + try: + test = UUID(docId) + except ValueError: + uuidValid = False + self.assertTrue(uuidValid) def testExportToPdfGeoreference(self): md = QgsProject.instance().metadata()