1 /***************************************************************************
2                               qgsabtractgeopdfexporter.cpp
3                              --------------------------
4     begin                : August 2019
5     copyright            : (C) 2019 by Nyall Dawson
6     email                : nyall dot dawson at gmail dot com
7  ***************************************************************************/
8 /***************************************************************************
9  *                                                                         *
10  *   This program is free software; you can redistribute it and/or modify  *
11  *   it under the terms of the GNU General Public License as published by  *
12  *   the Free Software Foundation; either version 2 of the License, or     *
13  *   (at your option) any later version.                                   *
14  *                                                                         *
15  ***************************************************************************/
16 
17 #include "qgsabstractgeopdfexporter.h"
18 #include "qgscoordinatetransformcontext.h"
19 #include "qgsrenderedfeaturehandlerinterface.h"
20 #include "qgsfeaturerequest.h"
21 #include "qgslogger.h"
22 #include "qgsgeometry.h"
23 #include "qgsvectorlayer.h"
24 #include "qgsvectorfilewriter.h"
25 
26 #include <gdal.h>
27 #include "qgsgdalutils.h"
28 #include "cpl_string.h"
29 
30 #include <QMutex>
31 #include <QMutexLocker>
32 #include <QDomDocument>
33 #include <QDomElement>
34 #include <QTimeZone>
35 #include <QUuid>
36 #include <QTextStream>
37 
geoPDFCreationAvailable()38 bool QgsAbstractGeoPdfExporter::geoPDFCreationAvailable()
39 {
40   // test if GDAL has read support in PDF driver
41   GDALDriverH hDriverMem = GDALGetDriverByName( "PDF" );
42   if ( !hDriverMem )
43   {
44     return false;
45   }
46 
47   const char *pHavePoppler = GDALGetMetadataItem( hDriverMem, "HAVE_POPPLER", nullptr );
48   if ( pHavePoppler && strstr( pHavePoppler, "YES" ) )
49     return true;
50 
51   const char *pHavePdfium = GDALGetMetadataItem( hDriverMem, "HAVE_PDFIUM", nullptr );
52   if ( pHavePdfium && strstr( pHavePdfium, "YES" ) )
53     return true;
54 
55   return false;
56 }
57 
geoPDFAvailabilityExplanation()58 QString QgsAbstractGeoPdfExporter::geoPDFAvailabilityExplanation()
59 {
60   // test if GDAL has read support in PDF driver
61   GDALDriverH hDriverMem = GDALGetDriverByName( "PDF" );
62   if ( !hDriverMem )
63   {
64     return QObject::tr( "No GDAL PDF driver available." );
65   }
66 
67   const char *pHavePoppler = GDALGetMetadataItem( hDriverMem, "HAVE_POPPLER", nullptr );
68   if ( pHavePoppler && strstr( pHavePoppler, "YES" ) )
69     return QString();
70 
71   const char *pHavePdfium = GDALGetMetadataItem( hDriverMem, "HAVE_PDFIUM", nullptr );
72   if ( pHavePdfium && strstr( pHavePdfium, "YES" ) )
73     return QString();
74 
75   return QObject::tr( "GDAL PDF driver was not built with PDF read support. A build with PDF read support is required for GeoPDF creation." );
76 }
77 
finalize(const QList<ComponentLayerDetail> & components,const QString & destinationFile,const ExportDetails & details)78 bool QgsAbstractGeoPdfExporter::finalize( const QList<ComponentLayerDetail> &components, const QString &destinationFile, const ExportDetails &details )
79 {
80   if ( details.includeFeatures && !saveTemporaryLayers() )
81     return false;
82 
83   const QString composition = createCompositionXml( components, details );
84   QgsDebugMsg( composition );
85   if ( composition.isEmpty() )
86     return false;
87 
88   // do the creation!
89   GDALDriverH driver = GDALGetDriverByName( "PDF" );
90   if ( !driver )
91   {
92     mErrorMessage = QObject::tr( "Cannot load GDAL PDF driver" );
93     return false;
94   }
95 
96   const QString xmlFilePath = generateTemporaryFilepath( QStringLiteral( "composition.xml" ) );
97   QFile file( xmlFilePath );
98   if ( file.open( QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate ) )
99   {
100     QTextStream out( &file );
101 #if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
102     out.setCodec( "UTF-8" );
103 #endif
104     out << composition;
105   }
106   else
107   {
108     mErrorMessage = QObject::tr( "Could not create GeoPDF composition file" );
109     return false;
110   }
111 
112   char **papszOptions = CSLSetNameValue( nullptr, "COMPOSITION_FILE", xmlFilePath.toUtf8().constData() );
113 
114   // return a non-null (fake) dataset in case of success, nullptr otherwise.
115   gdal::dataset_unique_ptr outputDataset( GDALCreate( driver, destinationFile.toUtf8().constData(), 0, 0, 0, GDT_Unknown, papszOptions ) );
116   bool res = outputDataset.get();
117   outputDataset.reset();
118 
119   CSLDestroy( papszOptions );
120 
121   return res;
122 }
123 
generateTemporaryFilepath(const QString & filename) const124 QString QgsAbstractGeoPdfExporter::generateTemporaryFilepath( const QString &filename ) const
125 {
126   return mTemporaryDir.filePath( filename );
127 }
128 
compositionModeSupported(QPainter::CompositionMode mode)129 bool QgsAbstractGeoPdfExporter::compositionModeSupported( QPainter::CompositionMode mode )
130 {
131   switch ( mode )
132   {
133     case QPainter::CompositionMode_SourceOver:
134     case QPainter::CompositionMode_Multiply:
135     case QPainter::CompositionMode_Screen:
136     case QPainter::CompositionMode_Overlay:
137     case QPainter::CompositionMode_Darken:
138     case QPainter::CompositionMode_Lighten:
139     case QPainter::CompositionMode_ColorDodge:
140     case QPainter::CompositionMode_ColorBurn:
141     case QPainter::CompositionMode_HardLight:
142     case QPainter::CompositionMode_SoftLight:
143     case QPainter::CompositionMode_Difference:
144     case  QPainter::CompositionMode_Exclusion:
145       return true;
146 
147     default:
148       break;
149   }
150 
151   return false;
152 }
153 
pushRenderedFeature(const QString & layerId,const QgsAbstractGeoPdfExporter::RenderedFeature & feature,const QString & group)154 void QgsAbstractGeoPdfExporter::pushRenderedFeature( const QString &layerId, const QgsAbstractGeoPdfExporter::RenderedFeature &feature, const QString &group )
155 {
156   // because map layers may be rendered in parallel, we need a mutex here
157   QMutexLocker locker( &mMutex );
158 
159   // collate all the features which belong to the same layer, replacing their geometries with the rendered feature bounds
160   QgsFeature f = feature.feature;
161   f.setGeometry( feature.renderedBounds );
162   mCollatedFeatures[ group ][ layerId ].append( f );
163 }
164 
saveTemporaryLayers()165 bool QgsAbstractGeoPdfExporter::saveTemporaryLayers()
166 {
167   for ( auto groupIt = mCollatedFeatures.constBegin(); groupIt != mCollatedFeatures.constEnd(); ++groupIt )
168   {
169     for ( auto it = groupIt->constBegin(); it != groupIt->constEnd(); ++it )
170     {
171       const QString filePath = generateTemporaryFilepath( it.key() + groupIt.key() + QStringLiteral( ".gpkg" ) );
172 
173       VectorComponentDetail detail = componentDetailForLayerId( it.key() );
174       detail.sourceVectorPath = filePath;
175       detail.group = groupIt.key();
176 
177       // write out features to disk
178       const QgsFeatureList features = it.value();
179       QString layerName;
180       QgsVectorFileWriter::SaveVectorOptions saveOptions;
181       saveOptions.driverName = QStringLiteral( "GPKG" );
182       saveOptions.symbologyExport = QgsVectorFileWriter::NoSymbology;
183       std::unique_ptr< QgsVectorFileWriter > writer( QgsVectorFileWriter::create( filePath, features.first().fields(), features.first().geometry().wkbType(), QgsCoordinateReferenceSystem(), QgsCoordinateTransformContext(), saveOptions, QgsFeatureSink::RegeneratePrimaryKey, nullptr, &layerName ) );
184       if ( writer->hasError() )
185       {
186         mErrorMessage = writer->errorMessage();
187         QgsDebugMsg( mErrorMessage );
188         return false;
189       }
190       for ( const QgsFeature &feature : features )
191       {
192         QgsFeature f = feature;
193         if ( !writer->addFeature( f, QgsFeatureSink::FastInsert ) )
194         {
195           mErrorMessage = writer->errorMessage();
196           QgsDebugMsg( mErrorMessage );
197           return false;
198         }
199       }
200       detail.sourceVectorLayer = layerName;
201       mVectorComponents << detail;
202     }
203   }
204   return true;
205 }
206 
createCompositionXml(const QList<ComponentLayerDetail> & components,const ExportDetails & details)207 QString QgsAbstractGeoPdfExporter::createCompositionXml( const QList<ComponentLayerDetail> &components, const ExportDetails &details )
208 {
209   QDomDocument doc;
210 
211   QDomElement compositionElem = doc.createElement( QStringLiteral( "PDFComposition" ) );
212 
213   // metadata tags
214   QDomElement metadata = doc.createElement( QStringLiteral( "Metadata" ) );
215   if ( !details.author.isEmpty() )
216   {
217     QDomElement author = doc.createElement( QStringLiteral( "Author" ) );
218     author.appendChild( doc.createTextNode( details.author ) );
219     metadata.appendChild( author );
220   }
221   if ( !details.producer.isEmpty() )
222   {
223     QDomElement producer = doc.createElement( QStringLiteral( "Producer" ) );
224     producer.appendChild( doc.createTextNode( details.producer ) );
225     metadata.appendChild( producer );
226   }
227   if ( !details.creator.isEmpty() )
228   {
229     QDomElement creator = doc.createElement( QStringLiteral( "Creator" ) );
230     creator.appendChild( doc.createTextNode( details.creator ) );
231     metadata.appendChild( creator );
232   }
233   if ( details.creationDateTime.isValid() )
234   {
235     QDomElement creationDate = doc.createElement( QStringLiteral( "CreationDate" ) );
236     QString creationDateString = QStringLiteral( "D:%1" ).arg( details.creationDateTime.toString( QStringLiteral( "yyyyMMddHHmmss" ) ) );
237     if ( details.creationDateTime.timeZone().isValid() )
238     {
239       int offsetFromUtc = details.creationDateTime.timeZone().offsetFromUtc( details.creationDateTime );
240       creationDateString += ( offsetFromUtc >= 0 ) ? '+' : '-';
241       offsetFromUtc = std::abs( offsetFromUtc );
242       int offsetHours = offsetFromUtc / 3600;
243       int offsetMins = ( offsetFromUtc % 3600 ) / 60;
244       creationDateString += QStringLiteral( "%1'%2'" ).arg( offsetHours ).arg( offsetMins );
245     }
246     creationDate.appendChild( doc.createTextNode( creationDateString ) );
247     metadata.appendChild( creationDate );
248   }
249   if ( !details.subject.isEmpty() )
250   {
251     QDomElement subject = doc.createElement( QStringLiteral( "Subject" ) );
252     subject.appendChild( doc.createTextNode( details.subject ) );
253     metadata.appendChild( subject );
254   }
255   if ( !details.title.isEmpty() )
256   {
257     QDomElement title = doc.createElement( QStringLiteral( "Title" ) );
258     title.appendChild( doc.createTextNode( details.title ) );
259     metadata.appendChild( title );
260   }
261   if ( !details.keywords.empty() )
262   {
263     QStringList allKeywords;
264     for ( auto it = details.keywords.constBegin(); it != details.keywords.constEnd(); ++it )
265     {
266       allKeywords.append( QStringLiteral( "%1: %2" ).arg( it.key(), it.value().join( ',' ) ) );
267     }
268     QDomElement keywords = doc.createElement( QStringLiteral( "Keywords" ) );
269     keywords.appendChild( doc.createTextNode( allKeywords.join( ';' ) ) );
270     metadata.appendChild( keywords );
271   }
272   compositionElem.appendChild( metadata );
273 
274   QMap< QString, QSet< QString > > createdLayerIds;
275   QMap< QString, QDomElement > groupLayerMap;
276   QMap< QString, QString > customGroupNamesToIds;
277 
278   QMultiMap< QString, QDomElement > pendingLayerTreeElements;
279 
280   if ( details.includeFeatures )
281   {
282     for ( const VectorComponentDetail &component : std::as_const( mVectorComponents ) )
283     {
284       if ( details.customLayerTreeGroups.contains( component.mapLayerId ) )
285         continue;
286 
287       QDomElement layer = doc.createElement( QStringLiteral( "Layer" ) );
288       layer.setAttribute( QStringLiteral( "id" ), component.group.isEmpty() ? component.mapLayerId : QStringLiteral( "%1_%2" ).arg( component.group, component.mapLayerId ) );
289       layer.setAttribute( QStringLiteral( "name" ), details.layerIdToPdfLayerTreeNameMap.contains( component.mapLayerId ) ? details.layerIdToPdfLayerTreeNameMap.value( component.mapLayerId ) : component.name );
290       layer.setAttribute( QStringLiteral( "initiallyVisible" ), details.initialLayerVisibility.value( component.mapLayerId, true ) ? QStringLiteral( "true" ) : QStringLiteral( "false" ) );
291 
292       if ( !component.group.isEmpty() )
293       {
294         if ( groupLayerMap.contains( component.group ) )
295         {
296           groupLayerMap[ component.group ].appendChild( layer );
297         }
298         else
299         {
300           QDomElement group = doc.createElement( QStringLiteral( "Layer" ) );
301           group.setAttribute( QStringLiteral( "id" ), QStringLiteral( "group_%1" ).arg( component.group ) );
302           group.setAttribute( QStringLiteral( "name" ), component.group );
303           group.setAttribute( QStringLiteral( "initiallyVisible" ), groupLayerMap.empty() ? QStringLiteral( "true" ) : QStringLiteral( "false" ) );
304           group.setAttribute( QStringLiteral( "mutuallyExclusiveGroupId" ), QStringLiteral( "__mutually_exclusive_groups__" ) );
305           pendingLayerTreeElements.insert( component.mapLayerId, group );
306           group.appendChild( layer );
307           groupLayerMap[ component.group ] = group;
308         }
309       }
310       else
311       {
312         pendingLayerTreeElements.insert( component.mapLayerId, layer );
313       }
314 
315       createdLayerIds[ component.group ].insert( component.mapLayerId );
316     }
317   }
318   // some PDF components may not be linked to vector components - e.g. layers with labels but no features (or raster layers)
319   for ( const ComponentLayerDetail &component : components )
320   {
321     if ( component.mapLayerId.isEmpty() || createdLayerIds.value( component.group ).contains( component.mapLayerId ) )
322       continue;
323 
324     if ( details.customLayerTreeGroups.contains( component.mapLayerId ) )
325       continue;
326 
327     QDomElement layer = doc.createElement( QStringLiteral( "Layer" ) );
328     layer.setAttribute( QStringLiteral( "id" ), component.group.isEmpty() ? component.mapLayerId : QStringLiteral( "%1_%2" ).arg( component.group, component.mapLayerId ) );
329     layer.setAttribute( QStringLiteral( "name" ), details.layerIdToPdfLayerTreeNameMap.contains( component.mapLayerId ) ? details.layerIdToPdfLayerTreeNameMap.value( component.mapLayerId ) : component.name );
330     layer.setAttribute( QStringLiteral( "initiallyVisible" ), details.initialLayerVisibility.value( component.mapLayerId, true ) ? QStringLiteral( "true" ) : QStringLiteral( "false" ) );
331 
332     if ( !component.group.isEmpty() )
333     {
334       if ( groupLayerMap.contains( component.group ) )
335       {
336         groupLayerMap[ component.group ].appendChild( layer );
337       }
338       else
339       {
340         QDomElement group = doc.createElement( QStringLiteral( "Layer" ) );
341         group.setAttribute( QStringLiteral( "id" ), QStringLiteral( "group_%1" ).arg( component.group ) );
342         group.setAttribute( QStringLiteral( "name" ), component.group );
343         group.setAttribute( QStringLiteral( "initiallyVisible" ), groupLayerMap.empty() ? QStringLiteral( "true" ) : QStringLiteral( "false" ) );
344         group.setAttribute( QStringLiteral( "mutuallyExclusiveGroupId" ), QStringLiteral( "__mutually_exclusive_groups__" ) );
345         pendingLayerTreeElements.insert( component.mapLayerId, group );
346         group.appendChild( layer );
347         groupLayerMap[ component.group ] = group;
348       }
349     }
350     else
351     {
352       pendingLayerTreeElements.insert( component.mapLayerId, layer );
353     }
354 
355     createdLayerIds[ component.group ].insert( component.mapLayerId );
356   }
357 
358   // layertree
359   QDomElement layerTree = doc.createElement( QStringLiteral( "LayerTree" ) );
360   //layerTree.setAttribute( QStringLiteral("displayOnlyOnVisiblePages"), QStringLiteral("true"));
361 
362   // create custom layer tree entries
363   for ( auto it = details.customLayerTreeGroups.constBegin(); it != details.customLayerTreeGroups.constEnd(); ++it )
364   {
365     if ( customGroupNamesToIds.contains( it.value() ) )
366       continue;
367 
368     QDomElement layer = doc.createElement( QStringLiteral( "Layer" ) );
369     const QString id = QUuid::createUuid().toString();
370     customGroupNamesToIds[ it.value() ] = id;
371     layer.setAttribute( QStringLiteral( "id" ), id );
372     layer.setAttribute( QStringLiteral( "name" ), it.value() );
373     layer.setAttribute( QStringLiteral( "initiallyVisible" ), QStringLiteral( "true" ) );
374     layerTree.appendChild( layer );
375   }
376 
377   // start by adding layer tree elements with known layer orders
378   for ( const QString &layerId : details.layerOrder )
379   {
380     const QList< QDomElement> elements = pendingLayerTreeElements.values( layerId );
381     for ( const QDomElement &element : elements )
382       layerTree.appendChild( element );
383   }
384   // then add all the rest (those we don't have an explicit order for)
385   for ( auto it = pendingLayerTreeElements.constBegin(); it != pendingLayerTreeElements.constEnd(); ++it )
386   {
387     if ( details.layerOrder.contains( it.key() ) )
388     {
389       // already added this one, just above...
390       continue;
391     }
392 
393     layerTree.appendChild( it.value() );
394   }
395 
396   compositionElem.appendChild( layerTree );
397 
398   // pages
399   QDomElement page = doc.createElement( QStringLiteral( "Page" ) );
400   QDomElement dpi = doc.createElement( QStringLiteral( "DPI" ) );
401   // hardcode DPI of 72 to get correct page sizes in outputs -- refs discussion in https://github.com/OSGeo/gdal/pull/2961
402   dpi.appendChild( doc.createTextNode( qgsDoubleToString( 72 ) ) );
403   page.appendChild( dpi );
404   // assumes DPI of 72, as noted above.
405   QDomElement width = doc.createElement( QStringLiteral( "Width" ) );
406   const double pageWidthPdfUnits = std::ceil( details.pageSizeMm.width() / 25.4 * 72 );
407   width.appendChild( doc.createTextNode( qgsDoubleToString( pageWidthPdfUnits ) ) );
408   page.appendChild( width );
409   QDomElement height = doc.createElement( QStringLiteral( "Height" ) );
410   const double pageHeightPdfUnits = std::ceil( details.pageSizeMm.height() / 25.4 * 72 );
411   height.appendChild( doc.createTextNode( qgsDoubleToString( pageHeightPdfUnits ) ) );
412   page.appendChild( height );
413 
414 
415   // georeferencing
416   int i = 0;
417   for ( const QgsAbstractGeoPdfExporter::GeoReferencedSection &section : details.georeferencedSections )
418   {
419     QDomElement georeferencing = doc.createElement( QStringLiteral( "Georeferencing" ) );
420     georeferencing.setAttribute( QStringLiteral( "id" ), QStringLiteral( "georeferenced_%1" ).arg( i++ ) );
421     georeferencing.setAttribute( QStringLiteral( "OGCBestPracticeFormat" ), details.useOgcBestPracticeFormatGeoreferencing ? QStringLiteral( "true" ) : QStringLiteral( "false" ) );
422     georeferencing.setAttribute( QStringLiteral( "ISO32000ExtensionFormat" ), details.useIso32000ExtensionFormatGeoreferencing ? QStringLiteral( "true" ) : QStringLiteral( "false" ) );
423 
424     if ( section.crs.isValid() )
425     {
426       QDomElement srs = doc.createElement( QStringLiteral( "SRS" ) );
427       // not currently used by GDAL or the PDF spec, but exposed in the GDAL XML schema. Maybe something we'll need to consider down the track...
428       // srs.setAttribute( QStringLiteral( "dataAxisToSRSAxisMapping" ), QStringLiteral( "2,1" ) );
429       if ( !section.crs.authid().startsWith( QStringLiteral( "user" ), Qt::CaseInsensitive ) )
430       {
431         srs.appendChild( doc.createTextNode( section.crs.authid() ) );
432       }
433       else
434       {
435         srs.appendChild( doc.createTextNode( section.crs.toWkt( QgsCoordinateReferenceSystem::WKT_PREFERRED_GDAL ) ) );
436       }
437       georeferencing.appendChild( srs );
438     }
439 
440     if ( !section.pageBoundsPolygon.isEmpty() )
441     {
442       /*
443         Define a polygon / neatline in PDF units into which the
444         Measure tool will display coordinates.
445         If not specified, BoundingBox will be used instead.
446         If none of BoundingBox and BoundingPolygon are specified,
447         the whole PDF page will be assumed to be georeferenced.
448        */
449       QDomElement boundingPolygon = doc.createElement( QStringLiteral( "BoundingPolygon" ) );
450 
451       // transform to PDF coordinate space
452       QTransform t = QTransform::fromTranslate( 0, pageHeightPdfUnits ).scale( pageWidthPdfUnits / details.pageSizeMm.width(),
453                      -pageHeightPdfUnits / details.pageSizeMm.height() );
454 
455       QgsPolygon p = section.pageBoundsPolygon;
456       p.transform( t );
457       boundingPolygon.appendChild( doc.createTextNode( p.asWkt() ) );
458 
459       georeferencing.appendChild( boundingPolygon );
460     }
461     else
462     {
463       /* Define the viewport where georeferenced coordinates are available.
464         If not specified, the extent of BoundingPolygon will be used instead.
465         If none of BoundingBox and BoundingPolygon are specified,
466         the whole PDF page will be assumed to be georeferenced.
467         */
468       QDomElement boundingBox = doc.createElement( QStringLiteral( "BoundingBox" ) );
469       boundingBox.setAttribute( QStringLiteral( "x1" ), qgsDoubleToString( section.pageBoundsMm.xMinimum() / 25.4 * 72 ) );
470       boundingBox.setAttribute( QStringLiteral( "y1" ), qgsDoubleToString( section.pageBoundsMm.yMinimum() / 25.4 * 72 ) );
471       boundingBox.setAttribute( QStringLiteral( "x2" ), qgsDoubleToString( section.pageBoundsMm.xMaximum() / 25.4 * 72 ) );
472       boundingBox.setAttribute( QStringLiteral( "y2" ), qgsDoubleToString( section.pageBoundsMm.yMaximum() / 25.4 * 72 ) );
473       georeferencing.appendChild( boundingBox );
474     }
475 
476     for ( const ControlPoint &point : section.controlPoints )
477     {
478       QDomElement cp1 = doc.createElement( QStringLiteral( "ControlPoint" ) );
479       cp1.setAttribute( QStringLiteral( "x" ), qgsDoubleToString( point.pagePoint.x() / 25.4 * 72 ) );
480       cp1.setAttribute( QStringLiteral( "y" ), qgsDoubleToString( ( details.pageSizeMm.height() - point.pagePoint.y() ) / 25.4 * 72 ) );
481       cp1.setAttribute( QStringLiteral( "GeoX" ), qgsDoubleToString( point.geoPoint.x() ) );
482       cp1.setAttribute( QStringLiteral( "GeoY" ), qgsDoubleToString( point.geoPoint.y() ) );
483       georeferencing.appendChild( cp1 );
484     }
485 
486     page.appendChild( georeferencing );
487   }
488 
489   auto createPdfDatasetElement = [&doc]( const ComponentLayerDetail & component ) -> QDomElement
490   {
491     QDomElement pdfDataset = doc.createElement( QStringLiteral( "PDF" ) );
492     pdfDataset.setAttribute( QStringLiteral( "dataset" ), component.sourcePdfPath );
493     if ( component.opacity != 1.0 || component.compositionMode != QPainter::CompositionMode_SourceOver )
494     {
495       QDomElement blendingElement = doc.createElement( QStringLiteral( "Blending" ) );
496       blendingElement.setAttribute( QStringLiteral( "opacity" ), component.opacity );
497       blendingElement.setAttribute( QStringLiteral( "function" ), compositionModeToString( component.compositionMode ) );
498 
499       pdfDataset.appendChild( blendingElement );
500     }
501     return pdfDataset;
502   };
503 
504   // content
505   QDomElement content = doc.createElement( QStringLiteral( "Content" ) );
506   for ( const ComponentLayerDetail &component : components )
507   {
508     if ( component.mapLayerId.isEmpty() )
509     {
510       content.appendChild( createPdfDatasetElement( component ) );
511     }
512     else if ( !component.group.isEmpty() )
513     {
514       // if content belongs to a group, we need nested "IfLayerOn" elements, one for the group and one for the layer
515       QDomElement ifGroupOn = doc.createElement( QStringLiteral( "IfLayerOn" ) );
516       ifGroupOn.setAttribute( QStringLiteral( "layerId" ), QStringLiteral( "group_%1" ).arg( component.group ) );
517       QDomElement ifLayerOn = doc.createElement( QStringLiteral( "IfLayerOn" ) );
518       if ( details.customLayerTreeGroups.contains( component.mapLayerId ) )
519         ifLayerOn.setAttribute( QStringLiteral( "layerId" ), customGroupNamesToIds.value( details.customLayerTreeGroups.value( component.mapLayerId ) ) );
520       else if ( component.group.isEmpty() )
521         ifLayerOn.setAttribute( QStringLiteral( "layerId" ), component.mapLayerId );
522       else
523         ifLayerOn.setAttribute( QStringLiteral( "layerId" ), QStringLiteral( "%1_%2" ).arg( component.group, component.mapLayerId ) );
524 
525       ifLayerOn.appendChild( createPdfDatasetElement( component ) );
526       ifGroupOn.appendChild( ifLayerOn );
527       content.appendChild( ifGroupOn );
528     }
529     else
530     {
531       QDomElement ifLayerOn = doc.createElement( QStringLiteral( "IfLayerOn" ) );
532       if ( details.customLayerTreeGroups.contains( component.mapLayerId ) )
533         ifLayerOn.setAttribute( QStringLiteral( "layerId" ), customGroupNamesToIds.value( details.customLayerTreeGroups.value( component.mapLayerId ) ) );
534       else if ( component.group.isEmpty() )
535         ifLayerOn.setAttribute( QStringLiteral( "layerId" ), component.mapLayerId );
536       else
537         ifLayerOn.setAttribute( QStringLiteral( "layerId" ), QStringLiteral( "%1_%2" ).arg( component.group, component.mapLayerId ) );
538       ifLayerOn.appendChild( createPdfDatasetElement( component ) );
539       content.appendChild( ifLayerOn );
540     }
541   }
542 
543   // vector datasets (we "draw" these on top, just for debugging... but they are invisible, so are never really drawn!)
544   if ( details.includeFeatures )
545   {
546     for ( const VectorComponentDetail &component : std::as_const( mVectorComponents ) )
547     {
548       QDomElement ifLayerOn = doc.createElement( QStringLiteral( "IfLayerOn" ) );
549       if ( details.customLayerTreeGroups.contains( component.mapLayerId ) )
550         ifLayerOn.setAttribute( QStringLiteral( "layerId" ), customGroupNamesToIds.value( details.customLayerTreeGroups.value( component.mapLayerId ) ) );
551       else if ( component.group.isEmpty() )
552         ifLayerOn.setAttribute( QStringLiteral( "layerId" ), component.mapLayerId );
553       else
554         ifLayerOn.setAttribute( QStringLiteral( "layerId" ), QStringLiteral( "%1_%2" ).arg( component.group, component.mapLayerId ) );
555       QDomElement vectorDataset = doc.createElement( QStringLiteral( "Vector" ) );
556       vectorDataset.setAttribute( QStringLiteral( "dataset" ), component.sourceVectorPath );
557       vectorDataset.setAttribute( QStringLiteral( "layer" ), component.sourceVectorLayer );
558       vectorDataset.setAttribute( QStringLiteral( "visible" ), QStringLiteral( "false" ) );
559       QDomElement logicalStructure = doc.createElement( QStringLiteral( "LogicalStructure" ) );
560       logicalStructure.setAttribute( QStringLiteral( "displayLayerName" ), component.name );
561       if ( !component.displayAttribute.isEmpty() )
562         logicalStructure.setAttribute( QStringLiteral( "fieldToDisplay" ), component.displayAttribute );
563       vectorDataset.appendChild( logicalStructure );
564       ifLayerOn.appendChild( vectorDataset );
565       content.appendChild( ifLayerOn );
566     }
567   }
568 
569   page.appendChild( content );
570   compositionElem.appendChild( page );
571 
572   doc.appendChild( compositionElem );
573 
574   QString composition;
575   QTextStream stream( &composition );
576   doc.save( stream, -1 );
577 
578   return composition;
579 }
580 
compositionModeToString(QPainter::CompositionMode mode)581 QString QgsAbstractGeoPdfExporter::compositionModeToString( QPainter::CompositionMode mode )
582 {
583   switch ( mode )
584   {
585     case QPainter::CompositionMode_SourceOver:
586       return QStringLiteral( "Normal" );
587 
588     case QPainter::CompositionMode_Multiply:
589       return QStringLiteral( "Multiply" );
590 
591     case QPainter::CompositionMode_Screen:
592       return QStringLiteral( "Screen" );
593 
594     case QPainter::CompositionMode_Overlay:
595       return QStringLiteral( "Overlay" );
596 
597     case QPainter::CompositionMode_Darken:
598       return QStringLiteral( "Darken" );
599 
600     case QPainter::CompositionMode_Lighten:
601       return QStringLiteral( "Lighten" );
602 
603     case QPainter::CompositionMode_ColorDodge:
604       return QStringLiteral( "ColorDodge" );
605 
606     case QPainter::CompositionMode_ColorBurn:
607       return QStringLiteral( "ColorBurn" );
608 
609     case QPainter::CompositionMode_HardLight:
610       return QStringLiteral( "HardLight" );
611 
612     case QPainter::CompositionMode_SoftLight:
613       return QStringLiteral( "SoftLight" );
614 
615     case QPainter::CompositionMode_Difference:
616       return QStringLiteral( "Difference" );
617 
618     case  QPainter::CompositionMode_Exclusion:
619       return QStringLiteral( "Exclusion" );
620 
621     default:
622       break;
623   }
624 
625   QgsDebugMsg( QStringLiteral( "Unsupported PDF blend mode %1" ).arg( mode ) );
626   return QStringLiteral( "Normal" );
627 }
628 
629