Write the same PDF for the same project when SOURCE_DATE_EPOCH is set

Two exports of one project differed in the creation and modification
dates (the time of the export, in local time) and in the document id
(random). With SOURCE_DATE_EPOCH set (reproducible-builds.org),
--export-pdf now uses the time it names, in UTC, and a document id
derived from the project: version 5 of the project uuid with the
SHA-256 of the project file, so it is the same for the same file and
changes when the file does. Qt has no setter for the dates, so
PdfLinks::setDocumentDate() rewrites them in the document information
and the XMP metadata after the file is written, fixing the XMP /Length
and shifting the xref table. Without the variable nothing changes.

Only --export-pdf reads it; the print window's PDF export does not, as
a person exporting by hand wants the real date.

qet_export (misc/qet-mcp) gains "reproducible" and "source_date_epoch".
It reports whether the build honoured the variable, since an older one
ignores it.

tst_pdfreproducible exports examples/741.qet four times and requires
identical bytes and the epoch's date; it fails with either this change
or the hash seed fix reverted. Projects with cross-reference links also
need their links in a fixed order, which is a separate fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-10-03 07:40:32 +13:00
parent 6e478e0e3f
commit 51d20abca4
8 changed files with 413 additions and 4 deletions
+36
View File
@@ -42,7 +42,9 @@
#include <QDir>
#include <QDirIterator>
#include <QDomDocument>
#include <QCryptographicHash>
#include <QDate>
#include <QDateTime>
#include <QFile>
#include <QSaveFile>
#include <QFileInfo>
@@ -59,7 +61,9 @@
#include <QSqlQuery>
#include <QSvgGenerator>
#include <QTextStream>
#include <QTimeZone>
#include <QTransform>
#include <QUuid>
namespace {
@@ -147,6 +151,23 @@ void renderDiagram(Diagram *diagram, QPainter &painter, const QRectF &target,
diagram->setDrawTerminalNames(was_drawing_terminal_names);
}
/// The time SOURCE_DATE_EPOCH names, in seconds since 1970 UTC, or an
/// invalid QDateTime when it is unset. A value that is not a whole number of
/// seconds is reported and ignored.
QDateTime sourceDateEpoch()
{
const QByteArray value = qgetenv("SOURCE_DATE_EPOCH");
if (value.isEmpty())
return {};
bool ok = false;
const qlonglong seconds = value.toLongLong(&ok);
if (!ok || seconds < 0) {
err << "SOURCE_DATE_EPOCH '" << value << "' is not a number of seconds; ignored.\n";
return {};
}
return QDateTime::fromSecsSinceEpoch(seconds, QTimeZone::UTC);
}
int exportPdf(QETProject &project, const QString &output,
bool showTerminals = false)
{
@@ -166,6 +187,19 @@ int exportPdf(QETProject &project, const QString &output,
writer.setCreator("QElectroTech");
writer.setResolution(96);
// 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.
const QDateTime sourceDate = sourceDateEpoch();
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()));
}
QPainter painter;
bool first = true;
for (Diagram *diagram : diagrams) {
@@ -240,6 +274,8 @@ 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())
PdfLinks::setDocumentDate(output, sourceDate);
out << "Exported " << diagrams.size() << " page(s) -> " << output << "\n";
return 0;