mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-10-05 10:34:13 +02:00
Give a repeatable PDF the same document id however the project was made (#1178)
With SOURCE_DATE_EPOCH set, the document id came from the project's uuid and a hash of the .qet file. A project generated again from the same data has new uuids, so its PDF differed in the id alone (XMP DocumentID and trailer /ID, 120 bytes) although every page was the same. Qt now writes a fixed placeholder id, and once the file is written the placeholder is replaced with an id derived from the bytes of the PDF: the same pages give the same id. Same length, so no offset changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+11
-13
@@ -42,7 +42,6 @@
|
||||
#include <QDir>
|
||||
#include <QDirIterator>
|
||||
#include <QDomDocument>
|
||||
#include <QCryptographicHash>
|
||||
#include <QDate>
|
||||
#include <QDateTime>
|
||||
#include <QFile>
|
||||
@@ -63,7 +62,6 @@
|
||||
#include <QTextStream>
|
||||
#include <QTimeZone>
|
||||
#include <QTransform>
|
||||
#include <QUuid>
|
||||
|
||||
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;
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
#include <private/qpdf_p.h>
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QCryptographicHash>
|
||||
#include <QFile>
|
||||
#include <QGraphicsTextItem>
|
||||
#include <QList>
|
||||
@@ -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);
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
#include <QRectF>
|
||||
#include <QString>
|
||||
#include <QTransform>
|
||||
#include <QUuid>
|
||||
#include <functional>
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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 <QDir>
|
||||
#include <QFile>
|
||||
#include <QProcess>
|
||||
#include <QRegularExpression>
|
||||
#include <QTemporaryDir>
|
||||
#include <QtTest>
|
||||
#include <QUuid>
|
||||
|
||||
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<QString, QString> 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 =
|
||||
|
||||
Reference in New Issue
Block a user