Merge pull request #1257 from ispyisail/feature/pdf-reproducible

Add repeatable PDF export when SOURCE_DATE_EPOCH is set
This commit is contained in:
Laurent Trinques
2026-10-02 23:15:05 +02:00
committed by GitHub
9 changed files with 461 additions and 6 deletions
+13
View File
@@ -433,6 +433,19 @@ target_link_libraries(tst_potentialtextcarrier PRIVATE Qt::Test)
target_compile_definitions(tst_potentialtextcarrier PRIVATE
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\"")
# With SOURCE_DATE_EPOCH set, --export-pdf writes the same bytes for the
# same project in every run. Exports examples/741.qet several times, in
# separate processes.
add_executable(
tst_pdfreproducible
tst_pdfreproducible.cpp)
add_test(NAME tst_pdfreproducible COMMAND tst_pdfreproducible)
add_dependencies(tst_pdfreproducible qelectrotech)
target_link_libraries(tst_pdfreproducible PRIVATE Qt::Test)
target_compile_definitions(tst_pdfreproducible PRIVATE
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\""
"QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"")
# Hops where wires cross (issue #436): the geometry in wirehops.cpp, and
# through the real binary, that --resave keeps the project setting and
# adds nothing to a project without it.
+119
View File
@@ -0,0 +1,119 @@
// SPDX-License-Identifier: GPL-2.0-or-later
/*
With SOURCE_DATE_EPOCH set, --export-pdf writes the same bytes for the
same project in every run: the dates are the ones it names, the document
id comes from the project file, and the fonts come in a fixed order.
Without it the dates are the time of the export, as before. Exported in
separate processes, since what used to differ changed between runs.
*/
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QProcess>
#include <QTemporaryDir>
#include <QtTest>
class tst_pdfreproducible : public QObject
{
Q_OBJECT
QTemporaryDir m_dir;
int m_run = 0;
QByteArray exportPdf(const QString &project, const QByteArray &epoch)
{
const int run = m_run++;
const QString home = m_dir.filePath(QStringLiteral("home%1").arg(run));
const QString tmp = m_dir.filePath(QStringLiteral("tmp%1").arg(run));
const QString out = m_dir.filePath(QStringLiteral("out%1.pdf").arg(run));
QDir().mkpath(home);
QDir().mkpath(tmp);
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen"));
env.insert(QStringLiteral("HOME"), home);
env.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/.config"));
env.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/.local/share"));
env.insert(QStringLiteral("TMPDIR"), tmp);
env.remove(QStringLiteral("QT_HASH_SEED"));
env.remove(QStringLiteral("SOURCE_DATE_EPOCH"));
if (!epoch.isEmpty())
env.insert(QStringLiteral("SOURCE_DATE_EPOCH"), QString::fromLatin1(epoch));
QProcess proc;
proc.setProcessEnvironment(env);
proc.start(QStringLiteral(QET_TEST_BINARY_PATH),
{QStringLiteral("--export-pdf"), project, out});
if (!proc.waitForFinished(120000) || proc.exitCode() != 0)
return {};
QFile file(out);
if (!file.open(QIODevice::ReadOnly))
return {};
return file.readAll();
}
/// Every object the xref table lists starts where the table says: the
/// dates are rewritten after Qt writes the file, which moves them.
static bool xrefMatches(const QByteArray &pdf)
{
const int sx = pdf.lastIndexOf("startxref");
if (sx == -1)
return false;
const int xref = pdf.mid(sx + 9).trimmed().split('\n').value(0).toInt();
if (!pdf.mid(xref).startsWith("xref"))
return false;
const QList<QByteArray> lines = pdf.mid(xref).split('\n');
const QList<QByteArray> header = lines.value(1).split(' ');
const int first = header.value(0).toInt();
const int count = header.value(1).toInt();
for (int i = 0; i < count; ++i) {
const QList<QByteArray> entry = lines.value(2 + i).split(' ');
if (entry.value(2) != "n")
continue;
const QByteArray obj = QByteArray::number(first + i) + " 0 obj";
if (pdf.mid(entry.value(0).toInt(), obj.size()) != obj)
return false;
}
return count > 0;
}
private slots:
void initTestCase()
{
QVERIFY(m_dir.isValid());
QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH)));
}
void sameBytesEveryRun()
{
// A project with no cross-reference links: their order is fixed
// separately.
const QString project =
QStringLiteral(QET_EXAMPLES_DIR) + QStringLiteral("/741.qet");
QVERIFY2(QFile::exists(project), "examples/741.qet not found");
const QByteArray first = exportPdf(project, "1700000000");
QVERIFY(!first.isEmpty());
QVERIFY(first.contains("/CreationDate (D:20231114221320Z)"));
#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0)
QVERIFY(first.contains("xmp:CreateDate=\"2023-11-14T22:13:20Z\""));
#endif
QVERIFY2(xrefMatches(first), "the xref table does not match the file");
for (int i = 0; i < 3; ++i)
QVERIFY2(exportPdf(project, "1700000000") == first,
"two exports of the same project differ");
}
void nowWithoutTheVariable()
{
const QString project =
QStringLiteral(QET_EXAMPLES_DIR) + QStringLiteral("/741.qet");
const QByteArray pdf = exportPdf(project, QByteArray());
QVERIFY(!pdf.isEmpty());
const QByteArray year =
"/CreationDate (D:" + QByteArray::number(QDate::currentDate().year());
QVERIFY(pdf.contains(year));
}
};
QTEST_MAIN(tst_pdfreproducible)
#include "tst_pdfreproducible.moc"