Skip to content

Commit a4dea99

Browse files
committedDec 6, 2017
Restore ability to save layouts to templates and add items from template
1 parent 59b6bf6 commit a4dea99

File tree

8 files changed

+306
-1
lines changed

8 files changed

+306
-1
lines changed
 

‎python/core/layout/qgslayout.sip

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,28 @@ class QgsLayout : QGraphicsScene, QgsExpressionContextGenerator, QgsLayoutUndoOb
417417
:rtype: list of QgsLayoutMultiFrame
418418
%End
419419

420+
bool saveAsTemplate( const QString &path, const QgsReadWriteContext &context ) const;
421+
%Docstring
422+
Saves the layout as a template at the given file ``path``.
423+
Returns true if save was successful.
424+
.. seealso:: loadFromTemplate()
425+
:rtype: bool
426+
%End
427+
428+
QList< QgsLayoutItem * > loadFromTemplate( const QDomDocument &document, const QgsReadWriteContext &context, bool clearExisting = true, bool *ok /Out/ = 0 );
429+
%Docstring
430+
Load a layout template ``document``.
431+
432+
By default this method will clear all items from the existing layout and real all layout
433+
settings from the template. Setting ``clearExisting`` to false will only add new items
434+
from the template, without overwriting the existing items or layout settings.
435+
436+
If ``ok`` is specified, it will be set to true if the load was successful.
437+
438+
Returns a list of loaded items.
439+
:rtype: list of QgsLayoutItem
440+
%End
441+
420442
QDomElement writeXml( QDomDocument &document, const QgsReadWriteContext &context ) const;
421443
%Docstring
422444
Returns the layout's state encapsulated in a DOM element.

‎src/app/layout/qgslayoutdesignerdialog.cpp

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
#include "qgslayoutmousehandles.h"
4545
#include "qgslayoutmodel.h"
4646
#include "qgslayoutitemslistview.h"
47+
#include "qgsproject.h"
4748
#include <QShortcut>
4849
#include <QComboBox>
4950
#include <QLineEdit>
@@ -52,6 +53,8 @@
5253
#include <QLabel>
5354
#include <QUndoView>
5455
#include <QTreeView>
56+
#include <QFileDialog>
57+
#include <QMessageBox>
5558

5659
#ifdef ENABLE_MODELTEST
5760
#include "modeltest.h"
@@ -299,6 +302,9 @@ QgsLayoutDesignerDialog::QgsLayoutDesignerDialog( QWidget *parent, Qt::WindowFla
299302
mActionPreviewProtanope->setActionGroup( previewGroup );
300303
mActionPreviewDeuteranope->setActionGroup( previewGroup );
301304

305+
connect( mActionSaveAsTemplate, &QAction::triggered, this, &QgsLayoutDesignerDialog::saveAsTemplate );
306+
connect( mActionLoadFromTemplate, &QAction::triggered, this, &QgsLayoutDesignerDialog::addItemsFromTemplate );
307+
302308
connect( mActionZoomIn, &QAction::triggered, mView, &QgsLayoutView::zoomIn );
303309
connect( mActionZoomOut, &QAction::triggered, mView, &QgsLayoutView::zoomOut );
304310
connect( mActionZoomAll, &QAction::triggered, mView, &QgsLayoutView::zoomFull );
@@ -1130,6 +1136,84 @@ void QgsLayoutDesignerDialog::undoRedoOccurredForItems( const QSet<QString> item
11301136
showItemOptions( focusItem );
11311137
}
11321138

1139+
void QgsLayoutDesignerDialog::saveAsTemplate()
1140+
{
1141+
//show file dialog
1142+
QgsSettings settings;
1143+
QString lastSaveDir = settings.value( QStringLiteral( "UI/lastComposerTemplateDir" ), QDir::homePath() ).toString();
1144+
#ifdef Q_OS_MAC
1145+
mQgis->activateWindow();
1146+
this->raise();
1147+
#endif
1148+
QString saveFileName = QFileDialog::getSaveFileName(
1149+
this,
1150+
tr( "Save template" ),
1151+
lastSaveDir,
1152+
tr( "Layout templates" ) + " (*.qpt *.QPT)" );
1153+
if ( saveFileName.isEmpty() )
1154+
return;
1155+
1156+
QFileInfo saveFileInfo( saveFileName );
1157+
//check if suffix has been added
1158+
if ( saveFileInfo.suffix().isEmpty() )
1159+
{
1160+
QString saveFileNameWithSuffix = saveFileName.append( ".qpt" );
1161+
saveFileInfo = QFileInfo( saveFileNameWithSuffix );
1162+
}
1163+
settings.setValue( QStringLiteral( "UI/lastComposerTemplateDir" ), saveFileInfo.absolutePath() );
1164+
1165+
QgsReadWriteContext context;
1166+
context.setPathResolver( QgsProject::instance()->pathResolver() );
1167+
if ( !currentLayout()->saveAsTemplate( saveFileName, context ) )
1168+
{
1169+
QMessageBox::warning( nullptr, tr( "Save template" ), tr( "Error creating template file." ) );
1170+
}
1171+
}
1172+
1173+
void QgsLayoutDesignerDialog::addItemsFromTemplate()
1174+
{
1175+
if ( !currentLayout() )
1176+
return;
1177+
1178+
QgsSettings settings;
1179+
QString openFileDir = settings.value( QStringLiteral( "UI/lastComposerTemplateDir" ), QDir::homePath() ).toString();
1180+
QString openFileString = QFileDialog::getOpenFileName( nullptr, tr( "Load template" ), openFileDir, tr( "Layout templates" ) + " (*.qpt *.QPT)" );
1181+
1182+
if ( openFileString.isEmpty() )
1183+
{
1184+
return; //canceled by the user
1185+
}
1186+
1187+
QFileInfo openFileInfo( openFileString );
1188+
settings.setValue( QStringLiteral( "UI/LastComposerTemplateDir" ), openFileInfo.absolutePath() );
1189+
1190+
QFile templateFile( openFileString );
1191+
if ( !templateFile.open( QIODevice::ReadOnly ) )
1192+
{
1193+
QMessageBox::warning( this, tr( "Load from template" ), tr( "Could not read template file." ) );
1194+
return;
1195+
}
1196+
1197+
QDomDocument templateDoc;
1198+
QgsReadWriteContext context;
1199+
context.setPathResolver( QgsProject::instance()->pathResolver() );
1200+
if ( templateDoc.setContent( &templateFile ) )
1201+
{
1202+
bool ok = false;
1203+
QList< QgsLayoutItem * > items = currentLayout()->loadFromTemplate( templateDoc, context, false, &ok );
1204+
if ( !ok )
1205+
{
1206+
QMessageBox::warning( this, tr( "Load from template" ), tr( "Could not read template file." ) );
1207+
return;
1208+
}
1209+
else
1210+
{
1211+
whileBlocking( currentLayout() )->deselectAll();
1212+
selectItems( items );
1213+
}
1214+
}
1215+
}
1216+
11331217
void QgsLayoutDesignerDialog::paste()
11341218
{
11351219
QPointF pt = mView->mapFromGlobal( QCursor::pos() );

‎src/app/layout/qgslayoutdesignerdialog.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,8 @@ class QgsLayoutDesignerDialog: public QMainWindow, private Ui::QgsLayoutDesigner
258258
void statusMessageReceived( const QString &message );
259259
void dockVisibilityChanged( bool visible );
260260
void undoRedoOccurredForItems( const QSet< QString > itemUuids );
261+
void saveAsTemplate();
262+
void addItemsFromTemplate();
261263

262264
private:
263265

‎src/core/layout/qgslayout.cpp

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,77 @@ QList<QgsLayoutMultiFrame *> QgsLayout::multiFrames() const
470470
return mMultiFrames;
471471
}
472472

473+
bool QgsLayout::saveAsTemplate( const QString &path, const QgsReadWriteContext &context ) const
474+
{
475+
QFile templateFile( path );
476+
if ( !templateFile.open( QIODevice::WriteOnly | QIODevice::Truncate ) )
477+
{
478+
return false;
479+
}
480+
481+
QDomDocument saveDocument;
482+
QDomElement elem = writeXml( saveDocument, context );
483+
saveDocument.appendChild( elem );
484+
485+
if ( templateFile.write( saveDocument.toByteArray() ) == -1 )
486+
return false;
487+
488+
return true;
489+
}
490+
491+
QList< QgsLayoutItem * > QgsLayout::loadFromTemplate( const QDomDocument &document, const QgsReadWriteContext &context, bool clearExisting, bool *ok )
492+
{
493+
if ( ok )
494+
*ok = false;
495+
496+
QList< QgsLayoutItem * > result;
497+
498+
if ( clearExisting )
499+
{
500+
clear();
501+
}
502+
503+
QDomDocument doc = document;
504+
505+
// remove all uuid attributes since we don't want duplicates UUIDS
506+
QDomNodeList composerItemsNodes = doc.elementsByTagName( QStringLiteral( "ComposerItem" ) );
507+
for ( int i = 0; i < composerItemsNodes.count(); ++i )
508+
{
509+
QDomNode composerItemNode = composerItemsNodes.at( i );
510+
if ( composerItemNode.isElement() )
511+
{
512+
composerItemNode.toElement().setAttribute( QStringLiteral( "templateUuid" ), composerItemNode.toElement().attribute( QStringLiteral( "uuid" ) ) );
513+
composerItemNode.toElement().removeAttribute( QStringLiteral( "uuid" ) );
514+
}
515+
}
516+
517+
//read general settings
518+
if ( clearExisting )
519+
{
520+
QDomElement layoutElem = doc.documentElement();
521+
if ( layoutElem.isNull() )
522+
{
523+
return result;
524+
}
525+
526+
bool loadOk = readXml( layoutElem, doc, context );
527+
if ( !loadOk )
528+
{
529+
return result;
530+
}
531+
layoutItems( result );
532+
}
533+
else
534+
{
535+
result = addItemsFromXml( doc.documentElement(), doc, context );
536+
}
537+
538+
if ( ok )
539+
*ok = true;
540+
541+
return result;
542+
}
543+
473544
QgsLayoutUndoStack *QgsLayout::undoStack()
474545
{
475546
return mUndoStack.get();

‎src/core/layout/qgslayout.h

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,26 @@ class CORE_EXPORT QgsLayout : public QGraphicsScene, public QgsExpressionContext
465465
*/
466466
QList< QgsLayoutMultiFrame * > multiFrames() const;
467467

468+
/**
469+
* Saves the layout as a template at the given file \a path.
470+
* Returns true if save was successful.
471+
* \see loadFromTemplate()
472+
*/
473+
bool saveAsTemplate( const QString &path, const QgsReadWriteContext &context ) const;
474+
475+
/**
476+
* Load a layout template \a document.
477+
*
478+
* By default this method will clear all items from the existing layout and real all layout
479+
* settings from the template. Setting \a clearExisting to false will only add new items
480+
* from the template, without overwriting the existing items or layout settings.
481+
*
482+
* If \a ok is specified, it will be set to true if the load was successful.
483+
*
484+
* Returns a list of loaded items.
485+
*/
486+
QList< QgsLayoutItem * > loadFromTemplate( const QDomDocument &document, const QgsReadWriteContext &context, bool clearExisting = true, bool *ok SIP_OUT = nullptr );
487+
468488
/**
469489
* Returns the layout's state encapsulated in a DOM element.
470490
* \see readXml()

‎src/gui/layout/qgslayoutview.cpp

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -719,6 +719,9 @@ void QgsLayoutView::deleteSelectedItems()
719719

720720
void QgsLayoutView::deleteItems( const QList<QgsLayoutItem *> &items )
721721
{
722+
if ( items.empty() )
723+
return;
724+
722725
currentLayout()->undoStack()->beginMacro( tr( "Delete Items" ) );
723726
//delete selected items
724727
for ( QgsLayoutItem *item : items )

‎src/ui/layout/qgslayoutdesignerbase.ui

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@
6262
<attribute name="toolBarBreak">
6363
<bool>false</bool>
6464
</attribute>
65+
<addaction name="mActionLoadFromTemplate"/>
66+
<addaction name="mActionSaveAsTemplate"/>
6567
</widget>
6668
<widget class="QToolBar" name="mToolsToolbar">
6769
<property name="windowTitle">
@@ -85,7 +87,7 @@
8587
<x>0</x>
8688
<y>0</y>
8789
<width>1083</width>
88-
<height>42</height>
90+
<height>25</height>
8991
</rect>
9092
</property>
9193
<widget class="QMenu" name="mLayoutMenu">
@@ -95,6 +97,9 @@
9597
<addaction name="mActionLayoutProperties"/>
9698
<addaction name="mActionAddPages"/>
9799
<addaction name="separator"/>
100+
<addaction name="mActionLoadFromTemplate"/>
101+
<addaction name="mActionSaveAsTemplate"/>
102+
<addaction name="separator"/>
98103
<addaction name="mActionClose"/>
99104
</widget>
100105
<widget class="QMenu" name="mItemMenu">
@@ -1057,6 +1062,30 @@
10571062
<string>Ctrl+Shift+V</string>
10581063
</property>
10591064
</action>
1065+
<action name="mActionSaveAsTemplate">
1066+
<property name="icon">
1067+
<iconset resource="../../../images/images.qrc">
1068+
<normaloff>:/images/themes/default/mActionFileSaveAs.svg</normaloff>:/images/themes/default/mActionFileSaveAs.svg</iconset>
1069+
</property>
1070+
<property name="text">
1071+
<string>Save as &amp;Template...</string>
1072+
</property>
1073+
<property name="toolTip">
1074+
<string>Save as template</string>
1075+
</property>
1076+
</action>
1077+
<action name="mActionLoadFromTemplate">
1078+
<property name="icon">
1079+
<iconset resource="../../../images/images.qrc">
1080+
<normaloff>:/images/themes/default/mActionFileOpen.svg</normaloff>:/images/themes/default/mActionFileOpen.svg</iconset>
1081+
</property>
1082+
<property name="text">
1083+
<string>&amp;Add Items from Template...</string>
1084+
</property>
1085+
<property name="toolTip">
1086+
<string>Add items from template</string>
1087+
</property>
1088+
</action>
10601089
</widget>
10611090
<resources>
10621091
<include location="../../../images/images.qrc"/>
@@ -1086,6 +1115,7 @@
10861115
<include location="../../../images/images.qrc"/>
10871116
<include location="../../../images/images.qrc"/>
10881117
<include location="../../../images/images.qrc"/>
1118+
<include location="../../../images/images.qrc"/>
10891119
</resources>
10901120
<connections/>
10911121
</ui>

‎tests/src/python/test_qgslayout.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414

1515
import qgis # NOQA
1616
import sip
17+
import tempfile
18+
import shutil
19+
import os
1720

1821
from qgis.core import (QgsUnitTypes,
1922
QgsLayout,
@@ -43,6 +46,16 @@
4346

4447
class TestQgsLayout(unittest.TestCase):
4548

49+
@classmethod
50+
def setUpClass(cls):
51+
"""Run before all tests"""
52+
cls.basetestpath = tempfile.mkdtemp()
53+
54+
@classmethod
55+
def tearDownClass(cls):
56+
"""Run after all tests"""
57+
shutil.rmtree(cls.basetestpath, True)
58+
4659
def testReadWriteXml(self):
4760
p = QgsProject()
4861
l = QgsLayout(p)
@@ -204,6 +217,66 @@ def testAddItemsFromXml(self):
204217

205218
#TODO - test restoring multiframe
206219

220+
def testSaveLoadTemplate(self):
221+
tmpfile = os.path.join(self.basetestpath, 'testTemplate.qpt')
222+
223+
p = QgsProject()
224+
l = QgsLayout(p)
225+
226+
# add some items
227+
item1 = QgsLayoutItemLabel(l)
228+
item1.setId('xxyyxx')
229+
item1.attemptMove(QgsLayoutPoint(4, 8, QgsUnitTypes.LayoutMillimeters))
230+
item1.attemptResize(QgsLayoutSize(18, 12, QgsUnitTypes.LayoutMillimeters))
231+
l.addItem(item1)
232+
item2 = QgsLayoutItemLabel(l)
233+
item2.setId('zzyyzz')
234+
item2.attemptMove(QgsLayoutPoint(1.4, 1.8, QgsUnitTypes.LayoutCentimeters))
235+
item2.attemptResize(QgsLayoutSize(2.8, 2.2, QgsUnitTypes.LayoutCentimeters))
236+
l.addItem(item2)
237+
238+
self.assertTrue(l.saveAsTemplate(tmpfile, QgsReadWriteContext()))
239+
240+
l2 = QgsLayout(p)
241+
with open(tmpfile) as f:
242+
template_content = f.read()
243+
doc = QDomDocument()
244+
doc.setContent(template_content)
245+
246+
# adding to existing items
247+
new_items, ok = l2.loadFromTemplate(doc, QgsReadWriteContext(), False)
248+
self.assertTrue(ok)
249+
self.assertEqual(len(new_items), 2)
250+
items = l2.items()
251+
self.assertTrue([i for i in items if i.id() == 'xxyyxx'])
252+
self.assertTrue([i for i in items if i.id() == 'zzyyzz'])
253+
self.assertTrue(new_items[0] in l2.items())
254+
self.assertTrue(new_items[1] in l2.items())
255+
256+
# adding to existing items
257+
new_items2, ok = l2.loadFromTemplate(doc, QgsReadWriteContext(), False)
258+
self.assertTrue(ok)
259+
self.assertEqual(len(new_items2), 2)
260+
items = l2.items()
261+
self.assertEqual(len(items), 4)
262+
self.assertTrue([i for i in items if i.id() == 'xxyyxx'])
263+
self.assertTrue([i for i in items if i.id() == 'zzyyzz'])
264+
self.assertTrue(new_items[0] in l2.items())
265+
self.assertTrue(new_items[1] in l2.items())
266+
self.assertTrue(new_items2[0] in l2.items())
267+
self.assertTrue(new_items2[1] in l2.items())
268+
269+
# clearing existing items
270+
new_items3, ok = l2.loadFromTemplate(doc, QgsReadWriteContext(), True)
271+
self.assertTrue(ok)
272+
self.assertEqual(len(new_items3), 2)
273+
items = l2.items()
274+
self.assertEqual(len(items), 2)
275+
self.assertTrue([i for i in items if i.id() == 'xxyyxx'])
276+
self.assertTrue([i for i in items if i.id() == 'zzyyzz'])
277+
self.assertTrue(new_items3[0] in l2.items())
278+
self.assertTrue(new_items3[1] in l2.items())
279+
207280
def testSelectedItems(self):
208281
p = QgsProject()
209282
l = QgsLayout(p)

0 commit comments

Comments
 (0)
Please sign in to comment.