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");