Give a repeatable PDF the same document id however the project was made (#1178)

With SOURCE_DATE_EPOCH set, the document id came from the project's uuid
and a hash of the .qet file. A project generated again from the same
data has new uuids, so its PDF differed in the id alone (XMP DocumentID
and trailer /ID, 120 bytes) although every page was the same.

Qt now writes a fixed placeholder id, and once the file is written the
placeholder is replaced with an id derived from the bytes of the PDF:
the same pages give the same id. Same length, so no offset changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-10-04 08:54:13 +13:00
parent f09be8007f
commit ef96b7d948
4 changed files with 98 additions and 14 deletions
+43 -1
View File
@@ -2,7 +2,7 @@
/*
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.
id comes from the content of the PDF, 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.
*/
@@ -10,8 +10,10 @@
#include <QDir>
#include <QFile>
#include <QProcess>
#include <QRegularExpression>
#include <QTemporaryDir>
#include <QtTest>
#include <QUuid>
class tst_pdfreproducible : public QObject
{
@@ -103,6 +105,46 @@ private slots:
"two exports of the same project differ");
}
void sameBytesWithNewUuids()
{
// #1178: a project generated again from the same data has the same
// drawing but new uuids, and its PDF used to differ in the
// document id, which came from the project file.
const QString project =
QStringLiteral(QET_EXAMPLES_DIR) + QStringLiteral("/741.qet");
QFile in(project);
QVERIFY(in.open(QIODevice::ReadOnly));
QString xml = QString::fromUtf8(in.readAll());
static const QRegularExpression uuid(QStringLiteral(
"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"));
QHash<QString, QString> renamed;
QString regenerated;
qsizetype last = 0;
for (auto it = uuid.globalMatch(xml); it.hasNext(); ) {
const auto match = it.next();
const QString key = match.captured().toLower();
if (!renamed.contains(key))
renamed.insert(key, QUuid::createUuid().toString(QUuid::WithoutBraces));
regenerated += xml.mid(last, match.capturedStart() - last) + renamed.value(key);
last = match.capturedEnd();
}
regenerated += xml.mid(last);
QVERIFY(renamed.size() > 10);
const QString copy = m_dir.filePath(QStringLiteral("regenerated.qet"));
QFile out(copy);
QVERIFY(out.open(QIODevice::WriteOnly));
out.write(regenerated.toUtf8());
out.close();
const QByteArray first = exportPdf(project, "1700000000");
QVERIFY(!first.isEmpty());
QVERIFY2(!first.contains("6f1c2d4e-9a3b-4c5d-8e7f-0a1b2c3d4e5f"),
"the placeholder document id was left in the file");
QVERIFY2(xrefMatches(first), "the xref table does not match the file");
QVERIFY2(exportPdf(copy, "1700000000") == first,
"the same drawing with new uuids gives a different PDF");
}
void nowWithoutTheVariable()
{
const QString project =