Skip to content

Commit

Permalink
feat(CMYK): Generate a valid PDF/X-4 file
Browse files Browse the repository at this point in the history
When a colorspace has been defined, we generate a valid PDF/X-4 file.
  • Loading branch information
troopa81 committed Aug 28, 2024
1 parent 64f4809 commit 29a1ea8
Show file tree
Hide file tree
Showing 3 changed files with 165 additions and 5 deletions.
108 changes: 103 additions & 5 deletions src/core/layout/qgslayoutexporter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -36,6 +37,10 @@
#include <QBuffer>
#include <QTimeZone>
#include <QTextStream>
#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0)
#include <QColorSpace>
#include <QPdfOutputIntent>
#endif

#include "gdal.h"
#include "cpl_conv.h"
Expand Down Expand Up @@ -650,8 +655,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();
Expand Down Expand Up @@ -1275,6 +1280,50 @@ 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()->metadata().title().isEmpty() ?
fi.baseName() : layout->project()->metadata().title();

QPdfWriter *pdfWriter = static_cast<QPdfWriter *>( device );
pdfWriter->setTitle( title );

QPagedPaintDevice::PdfVersion pdfVersion = QPagedPaintDevice::PdfVersion_1_4;

#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0)

const QgsProjectStyleSettings *styleSettings = layout->project() ? layout->project()->styleSettings() : nullptr;
if ( styleSettings )
{
// 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:
pdfWriter->setColorModel( QPdfWriter::ColorModel::CMYK );
break;

case Qgis::ColorModel::Rgb:
pdfWriter->setColorModel( QPdfWriter::ColorModel::RGB );
break;
}

const QColorSpace colorSpace = styleSettings->colorSpace();
if ( colorSpace.isValid() )
{
QPdfOutputIntent outputIntent;
outputIntent.setOutputProfile( colorSpace );
pdfWriter->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

pdfWriter->setPdfVersion( pdfVersion );
setXmpMetadata( pdfWriter, 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 );
Expand Down Expand Up @@ -1529,7 +1578,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
{
Expand Down Expand Up @@ -1714,7 +1763,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 );
Expand Down Expand Up @@ -2172,7 +2221,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() );
Expand All @@ -2190,3 +2239,52 @@ 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 uuid = pdfWriter->documentId();
#else
QUuid uuid = QUuid::createUuid();
#endif

// XMP metadata date format differs from PDF dictionary one
const QDateTime creationDateTime = layout->project()->metadata().creationDateTime();
const QByteArray creationDateMetadata = creationDateTime.toOffsetFromUtc( creationDateTime.offsetFromUtc() ).toString( Qt::ISODate ).toUtf8();

const QByteArray author = layout->project()->metadata().author().toUtf8();
const QByteArray creator = getCreator().toUtf8();
const QByteArray xmpMetadata =
"<?xpacket begin='' ?>\n"
"<x:xmpmeta xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\" xmlns:pdfaid=\"http://www.aiim.org/pdfa/ns/id/\" xmlns:pdfxid=\"http://www.npes.org/pdfx/ns/id/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:x=\"adobe:ns:meta/\" xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\" xmlns:xmpMM=\"http://ns.adobe.com/xap/1.0/mm/\">\n"
" <rdf:RDF>\n"
" <rdf:Description rdf:about=\"\">\n"
" <dc:title>\n"
" <rdf:Alt>\n"
" <rdf:li xml:lang=\"x-default\">" + pdfWriter->title().toUtf8() + "</rdf:li>\n"
" </rdf:Alt>\n"
" </dc:title>\n"
" <dc:creator>\n"
" <rdf:Seq>\n"
" <rdf:li>" + author + "</rdf:li>\n"
" </rdf:Seq>\n"
" </dc:creator>\n"
" </rdf:Description>\n"
" <rdf:Description pdf:Producer=\"" + creator + "\" pdf:Trapped=\"False\" rdf:about=\"\"/>\n"
" <rdf:Description rdf:about=\"\" xmp:CreateDate=\"" + creationDateMetadata + "\" xmp:CreatorTool=\"" + creator + "\" xmp:MetadataDate=\"" + creationDateMetadata + "\" xmp:ModifyDate=\"" + creationDateMetadata + "\"/>\n"
" <rdf:Description rdf:about=\"\" xmpMM:DocumentID=\"uuid:" + uuid.toByteArray( QUuid::WithBraces ) + "\" xmpMM:RenditionClass=\"default\" xmpMM:VersionID=\"1\"/>\n"
#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0)
// see qpdf.cpp QPdfEnginePrivate::writeXmpDocumentMetaData
+ ( pdfWriter->pdfVersion() == QPagedPaintDevice::PdfVersion_X4 ? " <rdf:Description pdfxid:GTS_PDFXVersion=\"PDF/X-4\" rdf:about=\"\"/>\n" : "" ) +
#endif
" </rdf:RDF>\n"
"</x:xmpmeta>\n"
"<?xpacket end='w'?>\n";

pdfWriter->setDocumentXmpMetadata( xmpMetadata );
}
6 changes: 6 additions & 0 deletions src/core/layout/qgslayoutexporter.h
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,12 @@ class CORE_EXPORT QgsLayoutExporter
const std::function<QString( QgsLayoutItem *item )> &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;
Expand Down
56 changes: 56 additions & 0 deletions tests/src/python/test_qgslayoutexporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,6 +33,7 @@
from qgis.PyQt.QtPrintSupport import QPrinter
from qgis.PyQt.QtSvg import QSvgRenderer
from qgis.core import (
Qgis,
QgsCoordinateReferenceSystem,
QgsFeature,
QgsFillSymbol,
Expand Down Expand Up @@ -523,6 +528,57 @@ def testExportToPdf(self):
self.assertEqual(metadata['KEYWORDS'], 'KWx: kw3,kw4;kw: kw1,kw2')
self.assertEqual(metadata['SUBJECT'], 'proj abstract')
self.assertEqual(metadata['TITLE'], 'proj title')
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, 'proj title')

creator = xmpDoc.findall("rdf:RDF/rdf:Description/dc:creator/rdf:Seq/rdf:li", namespaces)
self.assertEqual(len(creator), 1)
self.assertEqual(creator[0].text, 'proj author')

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()
Expand Down

0 comments on commit 29a1ea8

Please sign in to comment.