Merge pull request #1481 from ispyisail/fix/pdf-orientation-per-folio
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m39s

Add per-folio page orientation to the PDF export (#1480)
This commit is contained in:
Laurent Trinques
2026-10-14 17:27:15 +02:00
committed by GitHub
4 changed files with 260 additions and 4 deletions
+48 -4
View File
@@ -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();
+2
View File
@@ -96,6 +96,7 @@ class ProjectPrintWindow : public QMainWindow
void saveReloadDiagramParameters(Diagram *diagram, const ExportProperties &options, bool save);
QList<Diagram *> selectedDiagram() const;
void exportToPDF();
void setOrientationForDiagram(Diagram *diagram);
private:
@@ -106,6 +107,7 @@ class ProjectPrintWindow : public QMainWindow
QColor m_backup_diagram_background_color;
QHash<Diagram *, QCheckBox *> m_diagram_list_hash;
QList<PdfLinks::ComponentInfo> m_componentInfoList;
bool m_printing = false;
};
#endif // PROJECTPRINTWINDOW_H
+7
View File
@@ -748,6 +748,13 @@ target_link_libraries(tst_pdfpagesize PRIVATE Qt::Test Qt::Gui)
target_compile_definitions(tst_pdfpagesize PRIVATE
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\"")
# 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.
+203
View File
@@ -0,0 +1,203 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include "qetapptest.h"
#include "print/projectprintwindow.h"
#include "qetproject.h"
#include <QFile>
#include <QPrinter>
#include <QRegularExpression>
#include <QTemporaryDir>
// 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<QRectF> 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<Page> pages(const QString &pdf_path)
{
QFile file(pdf_path);
if (!file.open(QIODevice::ReadOnly))
return {};
const QString pdf = QString::fromLatin1(file.readAll());
QHash<int, QString> 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<Page> 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<Page> 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("<diagram [^>]*>"));
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<Page> 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<Page> 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<Page> mixed = exportPdf(&mixed_project, true);
QETProject portrait_project(portrait_path);
QCOMPARE(portrait_project.state(), QETProject::Ok);
const QList<Page> 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"