mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-10-03 09:14:12 +02:00
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:
@@ -35,6 +35,8 @@
|
||||
#include <QUrl>
|
||||
#include <QVector>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace PdfLinks {
|
||||
|
||||
void injectCrossRefLinks(QPdfEngine *engine, Diagram *diagram,
|
||||
@@ -411,6 +413,136 @@ void removeUnusedPdfxNamespace(const QString &pdfPath)
|
||||
f.close();
|
||||
}
|
||||
|
||||
void setDocumentDate(const QString &pdfPath, const QDateTime &when)
|
||||
{
|
||||
QFile f(pdfPath);
|
||||
if (!f.open(QIODevice::ReadOnly)) return;
|
||||
const QByteArray data = f.readAll();
|
||||
f.close();
|
||||
|
||||
const QDateTime utc = when.toUTC();
|
||||
const QByteArray pdfDate =
|
||||
"(D:" + utc.toString(QStringLiteral("yyyyMMddHHmmss")).toLatin1() + "Z)";
|
||||
const QByteArray xmpDate =
|
||||
utc.toString(QStringLiteral("yyyy-MM-ddTHH:mm:ss")).toLatin1() + "Z";
|
||||
|
||||
// Each edit replaces [start, end) of the original file.
|
||||
struct Edit { int start; int end; QByteArray text; };
|
||||
QList<Edit> edits;
|
||||
|
||||
// The document information: "/CreationDate (D:...)" and "/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;
|
||||
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.
|
||||
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;
|
||||
|
||||
// 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<Edit> 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);
|
||||
|
||||
std::sort(edits.begin(), edits.end(),
|
||||
[](const Edit &a, const Edit &b) { return a.start < b.start; });
|
||||
|
||||
// Where an offset in the original file is in the new one.
|
||||
auto moved = [&edits](int offset) {
|
||||
int shift = 0;
|
||||
for (const Edit &e : std::as_const(edits))
|
||||
if (e.end <= offset)
|
||||
shift += e.text.size() - (e.end - e.start);
|
||||
return offset + shift;
|
||||
};
|
||||
|
||||
// The xref table the last startxref points at, which is the one a
|
||||
// reader uses.
|
||||
const int sx = data.lastIndexOf("startxref");
|
||||
if (sx == -1) return;
|
||||
int numStart = sx + 9;
|
||||
while (numStart < data.size() && QChar(data.at(numStart)).isSpace())
|
||||
++numStart;
|
||||
int numEnd = numStart;
|
||||
while (numEnd < data.size() && QChar(data.at(numEnd)).isDigit())
|
||||
++numEnd;
|
||||
const int xref = data.mid(numStart, numEnd - numStart).toInt();
|
||||
if (numEnd == numStart || !data.mid(xref).startsWith("xref")) return;
|
||||
for (const Edit &e : std::as_const(edits))
|
||||
if (e.end > xref) return; // every edit is in the objects before it
|
||||
|
||||
QByteArray out;
|
||||
out.reserve(data.size() + 64);
|
||||
int pos = 0;
|
||||
for (const Edit &e : std::as_const(edits)) {
|
||||
if (e.start < pos) return; // overlapping: not a file Qt wrote
|
||||
out += data.mid(pos, e.start - pos);
|
||||
out += e.text;
|
||||
pos = e.end;
|
||||
}
|
||||
|
||||
// The xref entries: "0000012345 00000 n \n", 20 bytes each, after the
|
||||
// "xref" line and one "first count" line.
|
||||
int line = data.indexOf('\n', xref) + 1;
|
||||
line = data.indexOf('\n', line) + 1;
|
||||
if (line <= 0) return;
|
||||
out += data.mid(pos, line - pos);
|
||||
pos = line;
|
||||
static const QRegularExpression entry(QStringLiteral(R"(^(\d{10}) (\d{5}) ([nf])\s*$)"));
|
||||
while (pos + 18 <= data.size()) {
|
||||
const QByteArray row = data.mid(pos, 20);
|
||||
const auto match = entry.match(QString::fromLatin1(row));
|
||||
if (!match.hasMatch()) break;
|
||||
if (match.captured(3) == QLatin1String("n")) {
|
||||
const int offset = match.captured(1).toInt();
|
||||
out += QByteArray::number(moved(offset)).rightJustified(10, '0');
|
||||
out += row.mid(10);
|
||||
} else {
|
||||
out += row;
|
||||
}
|
||||
pos += 20;
|
||||
}
|
||||
out += data.mid(pos, numStart - pos);
|
||||
out += QByteArray::number(moved(xref));
|
||||
out += data.mid(numEnd);
|
||||
|
||||
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) return;
|
||||
f.write(out);
|
||||
f.close();
|
||||
}
|
||||
|
||||
void convertComponentInfoAnnotations(const QString &pdfPath,
|
||||
const QList<ComponentInfo> &annotations)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user