diff --git a/sources/cli_export.cpp b/sources/cli_export.cpp index 9a2b16a46..b6e20557a 100644 --- a/sources/cli_export.cpp +++ b/sources/cli_export.cpp @@ -42,7 +42,6 @@ #include #include #include -#include #include #include #include @@ -63,7 +62,6 @@ #include #include #include -#include namespace { @@ -189,18 +187,16 @@ 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. Before - // Qt 6.8 there is no document id to set: it is only written for PDF/A. + // id from what the PDF shows instead of a random one. Qt has no setter + // for the dates, and the content is not known yet, so both are + // rewritten once the file is written; Qt writes a fixed id until then. + // The id does not come from the project file, whose uuids are new each + // time a project is generated again from the same data. 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()); - if (file.open(QIODevice::ReadOnly)) - hash.addData(&file); - writer.setDocumentId(QUuid::createUuidV5(project.uuid(), hash.result())); - } + if (sourceDate.isValid()) + writer.setDocumentId(PdfLinks::placeholderDocumentId()); #endif QPainter painter; @@ -277,8 +273,10 @@ int exportPdf(QETProject &project, const QString &output, // the cross-references jump inside the document in any PDF viewer. PdfLinks::convertUriToGoTo(output); PdfLinks::removeUnusedPdfxNamespace(output); - if (sourceDate.isValid()) + if (sourceDate.isValid()) { PdfLinks::setDocumentDate(output, sourceDate); + PdfLinks::setDocumentIdFromContent(output); + } out << "Exported " << diagrams.size() << " page(s) -> " << output << "\n"; return 0; diff --git a/sources/pdf_links.cpp b/sources/pdf_links.cpp index 494342a37..9b591305f 100644 --- a/sources/pdf_links.cpp +++ b/sources/pdf_links.cpp @@ -28,6 +28,7 @@ #include #include +#include #include #include #include @@ -437,6 +438,35 @@ void removeUnusedPdfxNamespace(const QString &pdfPath) f.close(); } +QUuid placeholderDocumentId() +{ + // Any fixed uuid: it is also the namespace of the ids made from it. + return QUuid(QStringLiteral("{6f1c2d4e-9a3b-4c5d-8e7f-0a1b2c3d4e5f}")); +} + +void setDocumentIdFromContent(const QString &pdfPath) +{ + QFile f(pdfPath); + if (!f.open(QIODevice::ReadOnly)) return; + QByteArray data = f.readAll(); + f.close(); + + // Qt writes the id as text in the XMP ("uuid:6f1c...") and as the hex + // of that same text in the trailer ("/ID [ <3666...> <3666...> ]"). + const QUuid placeholder = placeholderDocumentId(); + const QByteArray text = placeholder.toByteArray(QUuid::WithoutBraces); + if (!data.contains(text) && !data.contains(text.toHex())) return; + + const QUuid id = QUuid::createUuidV5( + placeholder, QCryptographicHash::hash(data, QCryptographicHash::Sha256)); + const QByteArray idText = id.toByteArray(QUuid::WithoutBraces); + data.replace(text, idText); + data.replace(text.toHex(), idText.toHex()); + + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) return; + f.write(data); +} + void setDocumentDate(const QString &pdfPath, const QDateTime &when) { QFile f(pdfPath); diff --git a/sources/pdf_links.h b/sources/pdf_links.h index f404352da..2cc07af6d 100644 --- a/sources/pdf_links.h +++ b/sources/pdf_links.h @@ -24,6 +24,7 @@ #include #include #include +#include #include class QPdfEngine; @@ -94,6 +95,19 @@ namespace PdfLinks { */ void setDocumentDate(const QString &pdfPath, const QDateTime &when); + /// The document id to give QPdfWriter before a repeatable export, for + /// setDocumentIdFromContent() to find and replace afterwards. + QUuid placeholderDocumentId(); + + /** + Post-process a PDF written with placeholderDocumentId(): replace that + id, in the XMP metadata and in the trailer's /ID, with one derived + from the bytes of the file. The same pages give the same id, however + the project was made. Same length, so no offset changes. No-op when + the placeholder is absent. + */ + void setDocumentIdFromContent(const QString &pdfPath); + struct ComponentInfo { QString contents; }; diff --git a/tests/qttest/tst_pdfreproducible.cpp b/tests/qttest/tst_pdfreproducible.cpp index 049c9fce9..a1f37450d 100644 --- a/tests/qttest/tst_pdfreproducible.cpp +++ b/tests/qttest/tst_pdfreproducible.cpp @@ -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 #include #include +#include #include #include +#include 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 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 =