Build and work on Qt before 6.8

QPdfWriter::setDocumentId() is new in Qt 6.8, QTimeZone::UTC in 6.5 and
QHashSeed in 6.2, so the build failed on Qt 6.4 (Ubuntu 24.04). Qt before
6.8 also writes no /ModDate and no XMP metadata, and setDocumentDate()
then gave up and left the time of the export in the file. Those two are
now rewritten when present, and no document id is set where Qt writes
none.

tst_pdfreproducible also checks that the xref table still matches the
file after the dates are rewritten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJw3u5AdKQgQVPug8CzuMQ
This commit is contained in:
ispyisail
2026-10-03 09:30:11 +13:00
parent 51d20abca4
commit 8361a1efce
4 changed files with 78 additions and 35 deletions
+5 -2
View File
@@ -165,7 +165,7 @@ QDateTime sourceDateEpoch()
err << "SOURCE_DATE_EPOCH '" << value << "' is not a number of seconds; ignored.\n";
return {};
}
return QDateTime::fromSecsSinceEpoch(seconds, QTimeZone::UTC);
return QDateTime::fromSecsSinceEpoch(seconds, QTimeZone::utc());
}
int exportPdf(QETProject &project, const QString &output,
@@ -190,8 +190,10 @@ int exportPdf(QETProject &project, const QString &output,
// SOURCE_DATE_EPOCH (reproducible-builds.org) asks for the same file
// from the same input: the time it names instead of now, and a document
// id from the project file instead of a random one. Qt has no setter for
// the dates, so they are rewritten once the file is written.
// the dates, so they are rewritten once the file is written. Before
// Qt 6.8 there is no document id to set: it is only written for PDF/A.
const QDateTime sourceDate = sourceDateEpoch();
#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0)
if (sourceDate.isValid()) {
QCryptographicHash hash(QCryptographicHash::Sha256);
QFile file(project.filePath());
@@ -199,6 +201,7 @@ int exportPdf(QETProject &project, const QString &output,
hash.addData(&file);
writer.setDocumentId(QUuid::createUuidV5(project.uuid(), hash.result()));
}
#endif
QPainter painter;
bool first = true;
+6
View File
@@ -34,7 +34,9 @@
#include <QApplication>
#include <QDomImplementation>
#include <QFont>
#if QT_VERSION >= QT_VERSION_CHECK(6, 2, 0)
#include <QHashSeed>
#endif
#include <QSettings>
#include <QStyleFactory>
@@ -102,7 +104,11 @@ int main(int argc, char **argv)
// in QHash order). Setting QT_HASH_SEED alone came too late: Qt reads it
// once, when the first hash is made, and that happens before main().
// The variable is still set for the processes QElectroTech starts.
#if QT_VERSION >= QT_VERSION_CHECK(6, 2, 0)
QHashSeed::setDeterministicGlobalSeed();
#else
qSetGlobalQHashSeed(0);
#endif
qputenv("QT_HASH_SEED", "0");
//Some setup, notably to use with QSetting.
QCoreApplication::setOrganizationName("QElectroTech");
+39 -33
View File
@@ -430,51 +430,57 @@ void setDocumentDate(const QString &pdfPath, const QDateTime &when)
struct Edit { int start; int end; QByteArray text; };
QList<Edit> edits;
// The document information: "/CreationDate (D:...)" and "/ModDate (D:...)".
// Its other strings are UTF-16, so they cannot hold these keys.
// The document information: "/CreationDate (D:...)" and, from Qt 6.8,
// "/ModDate (D:...)". Its other strings are UTF-16, so they cannot hold
// these keys.
for (const QByteArray &key : {QByteArray("/CreationDate "), QByteArray("/ModDate ")}) {
const int k = data.indexOf(key);
if (k == -1) return;
if (k == -1) {
if (key.startsWith("/ModDate")) continue;
return;
}
const int start = k + key.size();
const int end = data.indexOf(')', start);
if (end == -1 || data.at(start) != '(') return;
edits.append({start, end + 1, pdfDate});
}
// The XMP metadata stream, which Qt writes uncompressed: its three
// dates, and its /Length, which changes with them.
// The XMP metadata stream, which Qt writes uncompressed from Qt 6.8
// (before, only for PDF/A): its three dates, and its /Length, which
// changes with them.
const QByteArray metadata("/Type /Metadata /Subtype /XML");
const int m = data.indexOf(metadata);
if (m == -1) return;
const QByteArray lengthKey("/Length ");
const int l = data.indexOf(lengthKey, m);
const int streamStart = data.indexOf("stream\n", m);
if (l == -1 || streamStart == -1 || l > streamStart) return;
const int lengthStart = l + lengthKey.size();
int lengthEnd = lengthStart;
while (lengthEnd < data.size() && QChar(data.at(lengthEnd)).isDigit())
++lengthEnd;
const int streamEnd = data.indexOf("endstream", streamStart);
if (lengthEnd == lengthStart || streamEnd == -1) return;
if (m != -1) {
const QByteArray lengthKey("/Length ");
const int l = data.indexOf(lengthKey, m);
const int streamStart = data.indexOf("stream\n", m);
if (l == -1 || streamStart == -1 || l > streamStart) return;
const int lengthStart = l + lengthKey.size();
int lengthEnd = lengthStart;
while (lengthEnd < data.size() && QChar(data.at(lengthEnd)).isDigit())
++lengthEnd;
const int streamEnd = data.indexOf("endstream", streamStart);
if (lengthEnd == lengthStart || streamEnd == -1) return;
// The last of each in the stream: the title and author come before
// the dates, and are text a project could fill with anything.
int xmpDelta = 0;
QList<Edit> xmpEdits;
for (const QByteArray &attr : {QByteArray("xmp:CreateDate=\""),
QByteArray("xmp:ModifyDate=\""),
QByteArray("xmp:MetadataDate=\"")}) {
const int a = data.lastIndexOf(attr, streamEnd);
if (a < streamStart) return;
const int start = a + attr.size();
const int end = data.indexOf('"', start);
if (end == -1 || end > streamEnd) return;
xmpEdits.append({start, end, xmpDate});
xmpDelta += xmpDate.size() - (end - start);
// The last of each in the stream: the title and author come before
// the dates, and are text a project could fill with anything.
int xmpDelta = 0;
QList<Edit> xmpEdits;
for (const QByteArray &attr : {QByteArray("xmp:CreateDate=\""),
QByteArray("xmp:ModifyDate=\""),
QByteArray("xmp:MetadataDate=\"")}) {
const int a = data.lastIndexOf(attr, streamEnd);
if (a < streamStart) return;
const int start = a + attr.size();
const int end = data.indexOf('"', start);
if (end == -1 || end > streamEnd) return;
xmpEdits.append({start, end, xmpDate});
xmpDelta += xmpDate.size() - (end - start);
}
const int length = data.mid(lengthStart, lengthEnd - lengthStart).toInt();
edits.append({lengthStart, lengthEnd, QByteArray::number(length + xmpDelta)});
edits.append(xmpEdits);
}
const int length = data.mid(lengthStart, lengthEnd - lengthStart).toInt();
edits.append({lengthStart, lengthEnd, QByteArray::number(length + xmpDelta)});
edits.append(xmpEdits);
std::sort(edits.begin(), edits.end(),
[](const Edit &a, const Edit &b) { return a.start < b.start; });
+28
View File
@@ -51,6 +51,31 @@ class tst_pdfreproducible : public QObject
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()
{
@@ -69,7 +94,10 @@ private slots:
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");