From f42958d2c2f9fab1b5f3d52661a4b6259b15db0d Mon Sep 17 00:00:00 2001 From: ispyisail Date: Wed, 14 Oct 2026 23:38:00 +1300 Subject: [PATCH] PDF export: turn each page to its folio's orientation (#1480) The print window exported every page in one orientation, landscape unless changed, so a portrait folio was shrunk onto a landscape page. With "Fit in page" on, a PDF export now turns each page to portrait or landscape as its folio is drawn. The preview keeps one orientation (QPrintPreviewWidget lays out every page at one paper size), and a printer keeps it too (only Qt's PDF engine accepts a new page layout once printing has started). The orientation chosen in the window is put back afterwards, so it is still the one saved. The cross-reference links took their target from painter->viewport(), which QPainter sets once when it opens, so on a turned page they missed their text. They now use the device's size, as render() does. tst_printpdforientation exports through the window: an A3 landscape and an A3 portrait folio get one page each way; tiled pages keep the chosen orientation; and the links on examples/industrial.qet's turned pages land where they do when the export starts portrait. Co-Authored-By: Claude Opus 5.5 --- sources/print/projectprintwindow.cpp | 52 +++++- sources/print/projectprintwindow.h | 2 + tests/qttest/CMakeLists.txt | 7 + tests/qttest/tst_printpdforientation.cpp | 203 +++++++++++++++++++++++ 4 files changed, 260 insertions(+), 4 deletions(-) create mode 100644 tests/qttest/tst_printpdforientation.cpp diff --git a/sources/print/projectprintwindow.cpp b/sources/print/projectprintwindow.cpp index e04ea1d44..4375e72b5 100644 --- a/sources/print/projectprintwindow.cpp +++ b/sources/print/projectprintwindow.cpp @@ -232,6 +232,20 @@ void ProjectPrintWindow::requestPaint() } } + // A PDF export turns each page to its folio's orientation (#1480): a + // portrait folio gets a portrait page instead of being shrunk onto a + // landscape one. Only the real export, with one page per folio: the + // preview draws every page at one paper size, and only Qt's PDF engine + // accepts a new page layout once printing has started. + const bool per_folio_orientation = m_printing + && m_printer->outputFormat() == QPrinter::PdfFormat + && ui->m_fit_in_page_cb->isChecked(); + const auto chosen_orientation = m_printer->pageLayout().orientation(); + const auto diagrams = selectedDiagram(); + if (per_folio_orientation && !diagrams.isEmpty()) { + setOrientationForDiagram(diagrams.first()); + } + bool first = true; QPainter painter(m_printer); int annotIndex = 0; @@ -245,12 +259,23 @@ void ProjectPrintWindow::requestPaint() // should be fixed by original author by evaluating function-result! (void)pdfExport; - for (auto diagram : selectedDiagram()) + for (auto diagram : diagrams) { - first ? first = false : m_printer->newPage(); + if (first) { + first = false; + } else { + if (per_folio_orientation) { + setOrientationForDiagram(diagram); + } + m_printer->newPage(); + } printDiagram(diagram, ui->m_fit_in_page_cb->isChecked(), &painter, m_printer, diagramPageMap, annotIndex); } + if (per_folio_orientation) { + m_printer->setPageOrientation(chosen_orientation); + } + // Note: do NOT call painter.end() or pdfConvertUriToGoTo() here. // We are inside the paintRequested slot: the QPrintPreviewWidget still // owns the paint cycle. On macOS arm64 (Metal/CALayer compositor), @@ -259,6 +284,19 @@ void ProjectPrintWindow::requestPaint() // pdfConvertUriToGoTo() is deferred to print() via QTimer::singleShot(0). } +/** + * @brief ProjectPrintWindow::setOrientationForDiagram + * Turn the next page to portrait or landscape, as @diagram is drawn. + * @param diagram + */ +void ProjectPrintWindow::setOrientationForDiagram(Diagram *diagram) +{ + const QRect rect = diagramRect(diagram, exportProperties()); + m_printer->setPageOrientation(rect.height() > rect.width() + ? QPageLayout::Portrait + : QPageLayout::Landscape); +} + /** * @brief ProjectPrintWindow::printDiagram * Print @diagram on the @printer @@ -356,8 +394,12 @@ void ProjectPrintWindow::printDiagram(Diagram *diagram, bool fit_page, QPainter // est revenu a l'identite ici. On reconstruit DONC explicitement la // transform appliquee par : // diagram->render(painter, QRectF(), diagram_rect, KeepAspectRatio) - // cible vide => painter->viewport() ; source = diagram_rect ; centre. - const QRectF target = QRectF(painter->viewport()); + // cible vide => taille du device ; source = diagram_rect ; centre. + // The target render() used for an empty one: the device's + // size. Not painter->viewport(), which is set once when the + // painter opens and misses a page turned since (#1480). + const QRectF target(0, 0, painter->device()->width(), + painter->device()->height()); const QRectF source = QRectF(diagram_rect); // meme source que render() // render() ANCRE en haut-gauche (pas de centrage) : @@ -852,8 +894,10 @@ void ProjectPrintWindow::print() const bool isPdf = (m_printer->outputFormat() == QPrinter::PdfFormat); const QString pdfFile = isPdf ? m_printer->outputFileName() : QString(); + m_printing = true; m_preview->print(); // triggers requestPaint() synchronously; painter // is created/destroyed inside that call + m_printing = false; savePageSetupForCurrentPrinter(); savePrintProperties(); diff --git a/sources/print/projectprintwindow.h b/sources/print/projectprintwindow.h index c023e0080..ab2a1e430 100644 --- a/sources/print/projectprintwindow.h +++ b/sources/print/projectprintwindow.h @@ -96,6 +96,7 @@ class ProjectPrintWindow : public QMainWindow void saveReloadDiagramParameters(Diagram *diagram, const ExportProperties &options, bool save); QList selectedDiagram() const; void exportToPDF(); + void setOrientationForDiagram(Diagram *diagram); private: @@ -106,6 +107,7 @@ class ProjectPrintWindow : public QMainWindow QColor m_backup_diagram_background_color; QHash m_diagram_list_hash; QList m_componentInfoList; + bool m_printing = false; }; #endif // PROJECTPRINTWINDOW_H diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index d76dc3d95..156722f5d 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -748,6 +748,13 @@ target_link_libraries(tst_pdfpagesize PRIVATE Qt::Test Qt::Gui) target_compile_definitions(tst_pdfpagesize PRIVATE "QET_TEST_BINARY_PATH=\"$\"") +# The print window's PDF export turns each page to its folio when "Fit in +# page" is on (#1480), and keeps the chosen orientation when it is off. The +# links on a turned page are checked on examples/industrial.qet. +qet_add_app_test(tst_printpdforientation tst_printpdforientation.cpp) +target_compile_definitions(tst_printpdforientation PRIVATE + "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") + # --export-pdf writes the cross-reference links in the same order in every # run, rather than in Element pointer order. Exports examples/industrial.qet # several times, in separate processes. diff --git a/tests/qttest/tst_printpdforientation.cpp b/tests/qttest/tst_printpdforientation.cpp new file mode 100644 index 000000000..2ad3ecb4d --- /dev/null +++ b/tests/qttest/tst_printpdforientation.cpp @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include "qetapptest.h" +#include "print/projectprintwindow.h" +#include "qetproject.h" + +#include +#include +#include +#include + +// The print window's PDF export turns each page to its folio (#1480). It +// used one orientation for the whole document, landscape unless changed, +// so a portrait folio was shrunk onto a landscape page. +// fixtures/pdf_page_a3.qet has an A3 landscape folio, then an A3 portrait +// one. +class tst_printpdforientation : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + + struct Page + { + QSizeF size; + QList links; + }; + + // Each page's MediaBox and the Rect of each of its links, in page + // order. Qt writes every object uncompressed, a page's annotations as + // "/Annots n 0 R" pointing at an array of annotation references. + static QList pages(const QString &pdf_path) + { + QFile file(pdf_path); + if (!file.open(QIODevice::ReadOnly)) + return {}; + const QString pdf = QString::fromLatin1(file.readAll()); + + QHash objects; + static const QRegularExpression object( + QStringLiteral(R"((\d+) 0 obj(.*?)endobj)"), + QRegularExpression::DotMatchesEverythingOption); + for (auto it = object.globalMatch(pdf); it.hasNext();) { + const auto m = it.next(); + objects.insert(m.captured(1).toInt(), m.captured(2)); + } + + static const QRegularExpression ref(QStringLiteral(R"((\d+) 0 R)")); + static const QRegularExpression kids( + QStringLiteral(R"(/Type\s*/Pages.*?/Kids\s*\[(.*?)\])"), + QRegularExpression::DotMatchesEverythingOption); + static const QRegularExpression box( + QStringLiteral(R"(/MediaBox \[0 0 ([0-9.]+) ([0-9.]+)\])")); + static const QRegularExpression annots(QStringLiteral(R"(/Annots (\d+) 0 R)")); + static const QRegularExpression link( + QStringLiteral(R"(/Subtype /Link\s*/Rect \[\s*([-0-9.]+)\s+([-0-9.]+)\s+([-0-9.]+)\s+([-0-9.]+)\s*\])")); + + QList result; + for (auto k = ref.globalMatch(kids.match(pdf).captured(1)); k.hasNext();) { + const QString page = objects.value(k.next().captured(1).toInt()); + const auto b = box.match(page); + Page p; + p.size = QSizeF(b.captured(1).toDouble(), b.captured(2).toDouble()); + const QString list = objects.value(annots.match(page).captured(1).toInt()); + for (auto a = ref.globalMatch(list); a.hasNext();) { + const auto l = link.match(objects.value(a.next().captured(1).toInt())); + if (l.hasMatch()) + p.links << QRectF(QPointF(l.captured(1).toDouble(), l.captured(2).toDouble()), + QPointF(l.captured(3).toDouble(), l.captured(4).toDouble())); + } + result << p; + } + return result; + } + + // Export @p project as the window's "Export in pdf" button does, from + // an A3 landscape printer with fixed margins. + QList exportPdf(QETProject *project, bool fit_in_page) + { + const QString out = m_dir.filePath(QStringLiteral("out.pdf")); + QFile::remove(out); + QSettings().setValue("print/default/fitinpage", fit_in_page); + + auto printer = new QPrinter(); + printer->setPageSize(QPageSize(QPageSize::A3)); + printer->setPageOrientation(QPageLayout::Landscape); + printer->setOutputFormat(QPrinter::PdfFormat); + printer->setOutputFileName(out); + + ProjectPrintWindow window(project, printer); + // After the window loaded the margins an earlier export saved. + printer->setPageMargins(QMarginsF(7, 7, 7, 7), QPageLayout::Millimeter); + if (!QMetaObject::invokeMethod(&window, "print")) + return {}; + QCoreApplication::processEvents(); // the deferred PDF post-processing + return pages(out); + } + + // examples/industrial.qet, its folios made portrait (taller rows), all + // of them or all but the first. + QString portraitIndustrial(bool first_too) + { + QFile file(QStringLiteral(QET_EXAMPLES_DIR "/industrial.qet")); + if (!file.open(QIODevice::ReadOnly)) + return {}; + QString xml = QString::fromUtf8(file.readAll()); + static const QRegularExpression diagram(QStringLiteral("]*>")); + static const QRegularExpression rows(QStringLiteral(R"(\brows="\d+")")); + QString result; + qsizetype last = 0; + int n = 0; + for (auto it = diagram.globalMatch(xml); it.hasNext();) { + const auto m = it.next(); + QString tag = m.captured(); + if (n++ > 0 || first_too) + tag.replace(rows, QStringLiteral("rows=\"20\"")); + result += xml.mid(last, m.capturedStart() - last) + tag; + last = m.capturedEnd(); + } + result += xml.mid(last); + + const QString path = m_dir.filePath( + first_too ? QStringLiteral("portrait.qet") : QStringLiteral("mixed.qet")); + QFile out(path); + if (!out.open(QIODevice::WriteOnly)) + return {}; + out.write(result.toUtf8()); + return path; + } + +private slots: + void initTestCase() + { + QVERIFY(m_dir.isValid()); + } + + void pagesFollowFolios() + { + const QString fixture = QFINDTESTDATA("fixtures/pdf_page_a3.qet"); + QVERIFY2(!fixture.isEmpty(), "fixture project not found"); + QETProject project(fixture); + QCOMPARE(project.state(), QETProject::Ok); + QCOMPARE(project.diagrams().size(), 2); + + const QList result = exportPdf(&project, true); + QCOMPARE(result.size(), 2); + const QSize a3 = QPageSize(QPageSize::A3).sizePoints(); + QCOMPARE(result.at(0).size, QSizeF(a3.height(), a3.width())); // landscape + QCOMPARE(result.at(1).size, QSizeF(a3)); // portrait + } + + // Without "Fit in page" a folio is spread over several pages, all in the + // orientation chosen in the window, as before. + void tiledPagesKeepChosenOrientation() + { + const QString fixture = QFINDTESTDATA("fixtures/pdf_page_a3.qet"); + QETProject project(fixture); + QCOMPARE(project.state(), QETProject::Ok); + + const QList result = exportPdf(&project, false); + QVERIFY(!result.isEmpty()); + for (const Page &page : result) + QVERIFY2(page.size.width() > page.size.height(), "a tiled page is not landscape"); + } + + // The cross-reference links on a page turned during the export land + // where they do when the export starts on that orientation: the same + // project with every folio portrait. + void linksOnTurnedPages() + { + const QString mixed_path = portraitIndustrial(false); + const QString portrait_path = portraitIndustrial(true); + QVERIFY(!mixed_path.isEmpty() && !portrait_path.isEmpty()); + + QETProject mixed_project(mixed_path); + QCOMPARE(mixed_project.state(), QETProject::Ok); + const QList mixed = exportPdf(&mixed_project, true); + QETProject portrait_project(portrait_path); + QCOMPARE(portrait_project.state(), QETProject::Ok); + const QList portrait = exportPdf(&portrait_project, true); + + QCOMPARE(mixed.size(), portrait.size()); + QVERIFY(mixed.size() > 1); + QVERIFY(mixed.first().size.width() > mixed.first().size.height()); + int links = 0; + for (int i = 1; i < mixed.size(); ++i) { + QVERIFY(mixed.at(i).size.height() > mixed.at(i).size.width()); + QCOMPARE(mixed.at(i).links.size(), portrait.at(i).links.size()); + for (int j = 0; j < mixed.at(i).links.size(); ++j) { + const QRectF a = mixed.at(i).links.at(j); + const QRectF b = portrait.at(i).links.at(j); + QVERIFY2(qAbs(a.left() - b.left()) < 0.01 && qAbs(a.top() - b.top()) < 0.01 + && qAbs(a.right() - b.right()) < 0.01 + && qAbs(a.bottom() - b.bottom()) < 0.01, + qPrintable(QStringLiteral("page %1 link %2 moved").arg(i + 1).arg(j))); + ++links; + } + } + QVERIFY2(links > 100, "too few links to test"); + } +}; + +QET_APP_TEST_MAIN(tst_printpdforientation) +#include "tst_printpdforientation.moc"