diff --git a/python/core/layout/qgslayout.sip b/python/core/layout/qgslayout.sip index e7ab07f20a2..29e013baa73 100644 --- a/python/core/layout/qgslayout.sip +++ b/python/core/layout/qgslayout.sip @@ -29,6 +29,16 @@ class QgsLayout : QGraphicsScene, QgsExpressionContextGenerator QgsLayout( QgsProject *project ); %Docstring Construct a new layout linked to the specified ``project``. + + If the layout is a "new" layout (as opposed to a layout which will + restore a previous state from XML) then initializeDefaults() should be + called on the new layout. +%End + + void initializeDefaults(); +%Docstring + Initializes an empty layout, e.g. by adding a default page to the layout. This should be called after creating + a new layout. %End QgsProject *project() const; @@ -191,6 +201,29 @@ class QgsLayout : QGraphicsScene, QgsExpressionContextGenerator :rtype: QgsLayoutPageCollection %End + QRectF layoutBounds( bool ignorePages = false, double margin = 0.0 ) const; +%Docstring + Calculates the bounds of all non-gui items in the layout. Ignores snap lines, mouse handles + and other cosmetic items. + \param ignorePages set to true to ignore page items + \param margin optional marginal (in percent, e.g., 0.05 = 5% ) to add around items + :return: layout bounds, in layout units. + :rtype: QRectF +%End + + void addLayoutItem( QgsLayoutItem *item /Transfer/ ); +%Docstring + Adds an ``item`` to the layout. This should be called instead of the base class addItem() + method. Ownership of the item is transferred to the layout. +%End + + public slots: + + void updateBounds(); +%Docstring + Updates the scene bounds of the layout. +%End + signals: void variablesChanged(); diff --git a/python/core/layout/qgslayoutpagecollection.sip b/python/core/layout/qgslayoutpagecollection.sip index 2c52d753e39..1963a072863 100644 --- a/python/core/layout/qgslayoutpagecollection.sip +++ b/python/core/layout/qgslayoutpagecollection.sip @@ -65,6 +65,9 @@ class QgsLayoutPageCollection : QObject to the collection, and the page will automatically be added to the collection's layout() (there is no need to manually add the page item to the layout). The page will be added after all pages currently contained in the collection. + + Calling addPage() automatically triggers a reflow() of pages. + .. seealso:: insertPage() %End @@ -80,6 +83,8 @@ class QgsLayoutPageCollection : QObject (Page numbers in collections begin at 0 - so a ``beforePage`` of 0 will insert the page before all existing pages). + Calling insertPage() automatically triggers a reflow() of pages. + .. seealso:: addPage() %End @@ -90,6 +95,8 @@ class QgsLayoutPageCollection : QObject Page numbers in collections begin at 0 - so a ``pageNumber`` of 0 will delete the first page in the collection. + + Calling deletePage() automatically triggers a reflow() of pages. %End void setPageStyleSymbol( QgsFillSymbol *symbol ); @@ -107,6 +114,19 @@ class QgsLayoutPageCollection : QObject :rtype: QgsFillSymbol %End + void reflow(); +%Docstring + Forces the page collection to reflow the arrangement of pages, e.g. to account + for page size/orientation change. +%End + + double maximumPageWidth() const; +%Docstring + Returns the maximum width of pages in the collection. The returned value is + in layout units. + :rtype: float +%End + }; /************************************************************************ diff --git a/src/core/layout/qgslayout.cpp b/src/core/layout/qgslayout.cpp index 1cd5e582f86..ac4b7902d28 100644 --- a/src/core/layout/qgslayout.cpp +++ b/src/core/layout/qgslayout.cpp @@ -25,6 +25,14 @@ QgsLayout::QgsLayout( QgsProject *project ) setBackgroundBrush( QColor( 215, 215, 215 ) ); } +void QgsLayout::initializeDefaults() +{ + // default to a A4 landscape page + QgsLayoutItemPage *page = new QgsLayoutItemPage( this ); + page->setPageSize( QgsLayoutSize( 297, 210, QgsUnitTypes::LayoutMillimeters ) ); + mPageCollection->addPage( page ); +} + QgsProject *QgsLayout::project() const { return mProject; @@ -112,3 +120,48 @@ QgsLayoutPageCollection *QgsLayout::pageCollection() { return mPageCollection.get(); } + +QRectF QgsLayout::layoutBounds( bool ignorePages, double margin ) const +{ + //start with an empty rectangle + QRectF bounds; + + //add all QgsComposerItems and QgsPaperItems which are in the composition + Q_FOREACH ( const QGraphicsItem *item, items() ) + { + const QgsLayoutItem *layoutItem = dynamic_cast( item ); + if ( !layoutItem ) + continue; + + bool isPage = layoutItem->type() == QgsLayoutItemRegistry::LayoutPage; + if ( !isPage || !ignorePages ) + { + //expand bounds with current item's bounds + if ( bounds.isValid() ) + bounds = bounds.united( item->sceneBoundingRect() ); + else + bounds = item->sceneBoundingRect(); + } + } + + if ( bounds.isValid() && margin > 0.0 ) + { + //finally, expand bounds out by specified margin of page size + double maxWidth = mPageCollection->maximumPageWidth(); + bounds.adjust( -maxWidth * margin, -maxWidth * margin, maxWidth * margin, maxWidth * margin ); + } + + return bounds; + +} + +void QgsLayout::addLayoutItem( QgsLayoutItem *item ) +{ + addItem( item ); + updateBounds(); +} + +void QgsLayout::updateBounds() +{ + setSceneRect( layoutBounds( false, 0.05 ) ); +} diff --git a/src/core/layout/qgslayout.h b/src/core/layout/qgslayout.h index 5c79b5e1979..4e580325031 100644 --- a/src/core/layout/qgslayout.h +++ b/src/core/layout/qgslayout.h @@ -45,9 +45,19 @@ class CORE_EXPORT QgsLayout : public QGraphicsScene, public QgsExpressionContext /** * Construct a new layout linked to the specified \a project. + * + * If the layout is a "new" layout (as opposed to a layout which will + * restore a previous state from XML) then initializeDefaults() should be + * called on the new layout. */ QgsLayout( QgsProject *project ); + /** + * Initializes an empty layout, e.g. by adding a default page to the layout. This should be called after creating + * a new layout. + */ + void initializeDefaults(); + /** * The project associated with the layout. Used to get access to layers, map themes, * relations and various other bits. It is never null. @@ -211,6 +221,28 @@ class CORE_EXPORT QgsLayout : public QGraphicsScene, public QgsExpressionContext */ QgsLayoutPageCollection *pageCollection(); + /** + * Calculates the bounds of all non-gui items in the layout. Ignores snap lines, mouse handles + * and other cosmetic items. + * \param ignorePages set to true to ignore page items + * \param margin optional marginal (in percent, e.g., 0.05 = 5% ) to add around items + * \returns layout bounds, in layout units. + */ + QRectF layoutBounds( bool ignorePages = false, double margin = 0.0 ) const; + + /** + * Adds an \a item to the layout. This should be called instead of the base class addItem() + * method. Ownership of the item is transferred to the layout. + */ + void addLayoutItem( QgsLayoutItem *item SIP_TRANSFER ); + + public slots: + + /** + * Updates the scene bounds of the layout. + */ + void updateBounds(); + signals: /** diff --git a/src/core/layout/qgslayoutpagecollection.cpp b/src/core/layout/qgslayoutpagecollection.cpp index ee2e5d7930c..0e338ad448a 100644 --- a/src/core/layout/qgslayoutpagecollection.cpp +++ b/src/core/layout/qgslayoutpagecollection.cpp @@ -17,6 +17,8 @@ #include "qgslayoutpagecollection.h" #include "qgslayout.h" +#define SPACE_BETWEEN_PAGES 10 + QgsLayoutPageCollection::QgsLayoutPageCollection( QgsLayout *layout ) : QObject( layout ) , mLayout( layout ) @@ -41,6 +43,29 @@ void QgsLayoutPageCollection::setPageStyleSymbol( QgsFillSymbol *symbol ) mPageStyleSymbol.reset( static_cast( symbol->clone() ) ); } +void QgsLayoutPageCollection::reflow() +{ + double currentY = 0; + QgsLayoutPoint p( 0, 0, mLayout->units() ); + Q_FOREACH ( QgsLayoutItemPage *page, mPages ) + { + page->attemptMove( p ); + currentY += mLayout->convertToLayoutUnits( page->pageSize() ).height() + SPACE_BETWEEN_PAGES; + p.setY( currentY ); + } + mLayout->updateBounds(); +} + +double QgsLayoutPageCollection::maximumPageWidth() const +{ + double maxWidth = 0; + Q_FOREACH ( QgsLayoutItemPage *page, mPages ) + { + maxWidth = qMax( maxWidth, mLayout->convertToLayoutUnits( page->pageSize() ).width() ); + } + return maxWidth; +} + QgsLayout *QgsLayoutPageCollection::layout() const { return mLayout; @@ -65,6 +90,7 @@ void QgsLayoutPageCollection::addPage( QgsLayoutItemPage *page ) { mPages.append( page ); mLayout->addItem( page ); + reflow(); } void QgsLayoutPageCollection::insertPage( QgsLayoutItemPage *page, int beforePage ) @@ -81,6 +107,7 @@ void QgsLayoutPageCollection::insertPage( QgsLayoutItemPage *page, int beforePag mPages.insert( beforePage, page ); } mLayout->addItem( page ); + reflow(); } void QgsLayoutPageCollection::deletePage( int pageNumber ) @@ -91,6 +118,7 @@ void QgsLayoutPageCollection::deletePage( int pageNumber ) QgsLayoutItemPage *page = mPages.takeAt( pageNumber ); mLayout->removeItem( page ); page->deleteLater(); + reflow(); } void QgsLayoutPageCollection::createDefaultPageStyleSymbol() diff --git a/src/core/layout/qgslayoutpagecollection.h b/src/core/layout/qgslayoutpagecollection.h index a1eb44e57c7..978d2b9b7d5 100644 --- a/src/core/layout/qgslayoutpagecollection.h +++ b/src/core/layout/qgslayoutpagecollection.h @@ -78,6 +78,9 @@ class CORE_EXPORT QgsLayoutPageCollection : public QObject * to the collection, and the page will automatically be added to the collection's * layout() (there is no need to manually add the page item to the layout). * The page will be added after all pages currently contained in the collection. + * + * Calling addPage() automatically triggers a reflow() of pages. + * * \see insertPage() */ void addPage( QgsLayoutItemPage *page SIP_TRANSFER ); @@ -93,6 +96,8 @@ class CORE_EXPORT QgsLayoutPageCollection : public QObject * (Page numbers in collections begin at 0 - so a \a beforePage of 0 will insert * the page before all existing pages). * + * Calling insertPage() automatically triggers a reflow() of pages. + * * \see addPage() */ void insertPage( QgsLayoutItemPage *page SIP_TRANSFER, int beforePage ); @@ -103,6 +108,8 @@ class CORE_EXPORT QgsLayoutPageCollection : public QObject * * Page numbers in collections begin at 0 - so a \a pageNumber of 0 will delete * the first page in the collection. + * + * Calling deletePage() automatically triggers a reflow() of pages. */ void deletePage( int pageNumber ); @@ -120,6 +127,18 @@ class CORE_EXPORT QgsLayoutPageCollection : public QObject */ const QgsFillSymbol *pageStyleSymbol() const { return mPageStyleSymbol.get(); } + /** + * Forces the page collection to reflow the arrangement of pages, e.g. to account + * for page size/orientation change. + */ + void reflow(); + + /** + * Returns the maximum width of pages in the collection. The returned value is + * in layout units. + */ + double maximumPageWidth() const; + private: QgsLayout *mLayout = nullptr; diff --git a/src/gui/layout/qgslayoutviewtooladditem.cpp b/src/gui/layout/qgslayoutviewtooladditem.cpp index 5e008f509dc..b172424e3a6 100644 --- a/src/gui/layout/qgslayoutviewtooladditem.cpp +++ b/src/gui/layout/qgslayoutviewtooladditem.cpp @@ -115,7 +115,7 @@ void QgsLayoutViewToolAddItem::layoutReleaseEvent( QgsLayoutViewMouseEvent *even settings.setValue( QStringLiteral( "LayoutDesigner/lastItemHeight" ), item->sizeWithUnits().height() ); settings.setValue( QStringLiteral( "LayoutDesigner/lastSizeUnit" ), static_cast< int >( item->sizeWithUnits().units() ) ); - layout()->addItem( item ); + layout()->addLayoutItem( item ); } void QgsLayoutViewToolAddItem::deactivate() diff --git a/tests/src/core/testqgslayout.cpp b/tests/src/core/testqgslayout.cpp index 61421717145..545053011c4 100644 --- a/tests/src/core/testqgslayout.cpp +++ b/tests/src/core/testqgslayout.cpp @@ -19,6 +19,8 @@ #include "qgstest.h" #include "qgsproject.h" #include "qgslayoutitemmap.h" +#include "qgslayoutitemshape.h" +#include "qgstestutils.h" class TestQgsLayout: public QObject { @@ -36,6 +38,8 @@ class TestQgsLayout: public QObject void variablesEdited(); void scope(); void referenceMap(); + void bounds(); + void addItem(); private: QString mReport; @@ -246,6 +250,117 @@ void TestQgsLayout::referenceMap() } +void TestQgsLayout::bounds() +{ + //add some items to a layout + QgsProject p; + QgsLayout l( &p ); + l.initializeDefaults(); + + QgsLayoutItemRectangularShape *shape1 = new QgsLayoutItemRectangularShape( &l ); + shape1->attemptResize( QgsLayoutSize( 90, 50 ) ); + shape1->attemptMove( QgsLayoutPoint( 90, 50 ) ); + shape1->setItemRotation( 45 ); + l.addLayoutItem( shape1 ); + QgsLayoutItemRectangularShape *shape2 = new QgsLayoutItemRectangularShape( &l ); + shape2->attemptResize( QgsLayoutSize( 110, 50 ) ); + shape2->attemptMove( QgsLayoutPoint( 100, 150 ) ); + l.addLayoutItem( shape2 ); + +#if 0 + QgsLayoutItemRectangularShape *shape3 = new QgsLayoutItemRectangularShape( &l ); + l.addLayoutItem( shape3 ); + shape3->setItemPosition( 210, 30, 50, 100, QgsComposerItem::UpperLeft, false, 2 ); + QgsLayoutItemRectangularShape *shape4 = new QgsLayoutItemRectangularShape( &l ); + l.addLayoutItem( shape4 ); + shape4->setItemPosition( 10, 120, 50, 30, QgsComposerItem::UpperLeft, false, 2 ); + shape4->setVisibility( false ); +#endif + + //check bounds + QRectF layoutBounds = l.layoutBounds( false ); +#if 0 // correct values when 2nd page items are added back in + QGSCOMPARENEAR( layoutBounds.height(), 372.15, 0.01 ); + QGSCOMPARENEAR( layoutBounds.width(), 301.00, 0.01 ); + QGSCOMPARENEAR( layoutBounds.left(), -2, 0.01 ); + QGSCOMPARENEAR( layoutBounds.top(), -2, 0.01 ); + + QRectF compositionBoundsNoPage = l.layoutBounds( true ); + QGSCOMPARENEAR( compositionBoundsNoPage.height(), 320.36, 0.01 ); + QGSCOMPARENEAR( compositionBoundsNoPage.width(), 250.30, 0.01 ); + QGSCOMPARENEAR( compositionBoundsNoPage.left(), 9.85, 0.01 ); + QGSCOMPARENEAR( compositionBoundsNoPage.top(), 49.79, 0.01 ); +#endif + + QGSCOMPARENEAR( layoutBounds.height(), 211.000000, 0.01 ); + QGSCOMPARENEAR( layoutBounds.width(), 298.000000, 0.01 ); + QGSCOMPARENEAR( layoutBounds.left(), -0.500000, 0.01 ); + QGSCOMPARENEAR( layoutBounds.top(), -0.500000, 0.01 ); + + QRectF compositionBoundsNoPage = l.layoutBounds( true ); + QGSCOMPARENEAR( compositionBoundsNoPage.height(), 175.704581, 0.01 ); + QGSCOMPARENEAR( compositionBoundsNoPage.width(), 125.704581, 0.01 ); + QGSCOMPARENEAR( compositionBoundsNoPage.left(), 84.795419, 0.01 ); + QGSCOMPARENEAR( compositionBoundsNoPage.top(), 24.795419, 0.01 ); + +#if 0 + QRectF page1Bounds = composition->pageItemBounds( 0, true ); + QGSCOMPARENEAR( page1Bounds.height(), 150.36, 0.01 ); + QGSCOMPARENEAR( page1Bounds.width(), 155.72, 0.01 ); + QGSCOMPARENEAR( page1Bounds.left(), 54.43, 0.01 ); + QGSCOMPARENEAR( page1Bounds.top(), 49.79, 0.01 ); + + QRectF page2Bounds = composition->pageItemBounds( 1, true ); + QGSCOMPARENEAR( page2Bounds.height(), 100.30, 0.01 ); + QGSCOMPARENEAR( page2Bounds.width(), 50.30, 0.01 ); + QGSCOMPARENEAR( page2Bounds.left(), 209.85, 0.01 ); + QGSCOMPARENEAR( page2Bounds.top(), 249.85, 0.01 ); + + QRectF page2BoundsWithHidden = composition->pageItemBounds( 1, false ); + QGSCOMPARENEAR( page2BoundsWithHidden.height(), 120.30, 0.01 ); + QGSCOMPARENEAR( page2BoundsWithHidden.width(), 250.30, 0.01 ); + QGSCOMPARENEAR( page2BoundsWithHidden.left(), 9.85, 0.01 ); + QGSCOMPARENEAR( page2BoundsWithHidden.top(), 249.85, 0.01 ); +#endif +} + +void TestQgsLayout::addItem() +{ + QgsProject p; + QgsLayout l( &p ); + l.pageCollection()->deletePage( 0 ); + + QgsLayoutItemRectangularShape *shape1 = new QgsLayoutItemRectangularShape( &l ); + shape1->attemptResize( QgsLayoutSize( 140, 70 ) ); + shape1->attemptMove( QgsLayoutPoint( 90, 50 ) ); + + l.addLayoutItem( shape1 ); + QVERIFY( l.items().contains( shape1 ) ); + // bounds should be updated to include item + QGSCOMPARENEAR( l.sceneRect().left(), 89.5, 0.001 ); + QGSCOMPARENEAR( l.sceneRect().top(), 49.5, 0.001 ); + QGSCOMPARENEAR( l.sceneRect().width(), 141, 0.001 ); + QGSCOMPARENEAR( l.sceneRect().height(), 71, 0.001 ); + + QgsLayoutItemRectangularShape *shape2 = new QgsLayoutItemRectangularShape( &l ); + shape2->attemptResize( QgsLayoutSize( 240, 170 ) ); + shape2->attemptMove( QgsLayoutPoint( 30, 20 ) ); + + // don't use addLayoutItem - we want to manually trigger a bounds update + l.addItem( shape2 ); + QGSCOMPARENEAR( l.sceneRect().left(), 89.5, 0.001 ); + QGSCOMPARENEAR( l.sceneRect().top(), 49.5, 0.001 ); + QGSCOMPARENEAR( l.sceneRect().width(), 141, 0.001 ); + QGSCOMPARENEAR( l.sceneRect().height(), 71, 0.001 ); + + l.updateBounds(); + // bounds should be updated to include item + QGSCOMPARENEAR( l.sceneRect().left(), 29.5, 0.001 ); + QGSCOMPARENEAR( l.sceneRect().top(), 19.5, 0.001 ); + QGSCOMPARENEAR( l.sceneRect().width(), 241, 0.001 ); + QGSCOMPARENEAR( l.sceneRect().height(), 171, 0.001 ); +} + QGSTEST_MAIN( TestQgsLayout ) #include "testqgslayout.moc" diff --git a/tests/src/python/test_qgslayoutpagecollection.py b/tests/src/python/test_qgslayoutpagecollection.py index fb762538de0..7be0d6aace2 100644 --- a/tests/src/python/test_qgslayoutpagecollection.py +++ b/tests/src/python/test_qgslayoutpagecollection.py @@ -15,7 +15,14 @@ __revision__ = '$Format:%H$' import qgis # NOQA import sip -from qgis.core import QgsUnitTypes, QgsLayout, QgsLayoutItemPage, QgsProject, QgsLayoutPageCollection, QgsSimpleFillSymbolLayer, QgsFillSymbol +from qgis.core import (QgsUnitTypes, + QgsLayout, + QgsLayoutItemPage, + QgsLayoutSize, + QgsProject, + QgsLayoutPageCollection, + QgsSimpleFillSymbolLayer, + QgsFillSymbol) from qgis.PyQt.QtCore import Qt, QCoreApplication, QEvent from qgis.testing import start_app, unittest @@ -120,6 +127,86 @@ class TestQgsLayoutPageCollection(unittest.TestCase): self.assertTrue(sip.isdeleted(page)) self.assertTrue(sip.isdeleted(page2)) + def testMaxPageWidth(self): + """ + Test calculating maximum page width + """ + p = QgsProject() + l = QgsLayout(p) + collection = l.pageCollection() + + # add a page + page = QgsLayoutItemPage(l) + page.setPageSize('A4') + collection.addPage(page) + self.assertEqual(collection.maximumPageWidth(), 210.0) + + # add a second page + page2 = QgsLayoutItemPage(l) + page2.setPageSize('A3') + collection.addPage(page2) + self.assertEqual(collection.maximumPageWidth(), 297.0) + + # add a page with other units + page3 = QgsLayoutItemPage(l) + page3.setPageSize(QgsLayoutSize(100, 100, QgsUnitTypes.LayoutMeters)) + collection.addPage(page3) + self.assertEqual(collection.maximumPageWidth(), 100000.0) + + def testReflow(self): + """ + Test reflowing pages + """ + p = QgsProject() + l = QgsLayout(p) + collection = l.pageCollection() + + #add a page + page = QgsLayoutItemPage(l) + page.setPageSize('A4') + collection.addPage(page) + + #should be positioned at origin + self.assertEqual(page.pos().x(), 0) + self.assertEqual(page.pos().y(), 0) + + #second page + page2 = QgsLayoutItemPage(l) + page2.setPageSize('A5') + collection.addPage(page2) + + self.assertEqual(page.pos().x(), 0) + self.assertEqual(page.pos().y(), 0) + self.assertEqual(page2.pos().x(), 0) + self.assertEqual(page2.pos().y(), 307) + + #third page, slotted in middle + page3 = QgsLayoutItemPage(l) + page3.setPageSize('A3') + collection.insertPage(page3, 1) + + self.assertEqual(page.pos().x(), 0) + self.assertEqual(page.pos().y(), 0) + self.assertEqual(page2.pos().x(), 0) + self.assertEqual(page2.pos().y(), 737) + self.assertEqual(page3.pos().x(), 0) + self.assertEqual(page3.pos().y(), 307) + + page.setPageSize(QgsLayoutSize(100, 120)) + # no update until reflow is called + self.assertEqual(page.pos().x(), 0) + self.assertEqual(page.pos().y(), 0) + self.assertEqual(page2.pos().x(), 0) + self.assertEqual(page2.pos().y(), 737) + self.assertEqual(page3.pos().x(), 0) + self.assertEqual(page3.pos().y(), 307) + collection.reflow() + self.assertEqual(page.pos().x(), 0) + self.assertEqual(page.pos().y(), 0) + self.assertEqual(page2.pos().x(), 0) + self.assertEqual(page2.pos().y(), 560) + self.assertEqual(page3.pos().x(), 0) + self.assertEqual(page3.pos().y(), 130) if __name__ == '__main__': unittest.main()