From 8361a1efce32919726a249549e09964a3f269811 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sat, 3 Oct 2026 09:30:11 +1300 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01PJw3u5AdKQgQVPug8CzuMQ --- sources/cli_export.cpp | 7 ++- sources/main.cpp | 6 +++ sources/pdf_links.cpp | 72 +++++++++++++++------------- tests/qttest/tst_pdfreproducible.cpp | 28 +++++++++++ 4 files changed, 78 insertions(+), 35 deletions(-) diff --git a/sources/cli_export.cpp b/sources/cli_export.cpp index b26a3c27e..9a2b16a46 100644 --- a/sources/cli_export.cpp +++ b/sources/cli_export.cpp @@ -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; diff --git a/sources/main.cpp b/sources/main.cpp index 594e53747..8914dffc7 100644 --- a/sources/main.cpp +++ b/sources/main.cpp @@ -34,7 +34,9 @@ #include #include #include +#if QT_VERSION >= QT_VERSION_CHECK(6, 2, 0) #include +#endif #include #include @@ -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"); diff --git a/sources/pdf_links.cpp b/sources/pdf_links.cpp index 1a6adeceb..6cfec687e 100644 --- a/sources/pdf_links.cpp +++ b/sources/pdf_links.cpp @@ -430,51 +430,57 @@ void setDocumentDate(const QString &pdfPath, const QDateTime &when) struct Edit { int start; int end; QByteArray text; }; QList 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 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 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; }); diff --git a/tests/qttest/tst_pdfreproducible.cpp b/tests/qttest/tst_pdfreproducible.cpp index ff60fac44..049c9fce9 100644 --- a/tests/qttest/tst_pdfreproducible.cpp +++ b/tests/qttest/tst_pdfreproducible.cpp @@ -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 lines = pdf.mid(xref).split('\n'); + const QList 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 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");