mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-10-03 01:04:13 +02:00
8361a1efce
QPdfWriter::setDocumentId() is new in Qt 6.8, QTimeZone::UTC in 6.5 and QHashSeed in 6.2, so the build failed on Qt 6.4 (Ubuntu 24.04). Qt before 6.8 also writes no /ModDate and no XMP metadata, and setDocumentDate() then gave up and left the time of the export in the file. Those two are now rewritten when present, and no document id is set where Qt writes none. tst_pdfreproducible also checks that the xref table still matches the file after the dates are rewritten. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PJw3u5AdKQgQVPug8CzuMQ
818 lines
26 KiB
C++
818 lines
26 KiB
C++
/*
|
|
Copyright 2006-2025 The QElectroTech Team
|
|
This file is part of QElectroTech.
|
|
|
|
QElectroTech is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 2 of the License, or
|
|
(at your option) any later version.
|
|
|
|
QElectroTech is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
#include "pdf_links.h"
|
|
|
|
#include "diagram.h"
|
|
#include "qetgraphicsitem/crossrefitem.h"
|
|
#include "qetgraphicsitem/dynamicelementtextitem.h"
|
|
#include "qetgraphicsitem/element.h"
|
|
#include "qetgraphicsitem/elementtextitemgroup.h"
|
|
|
|
// Private Qt PDF engine for drawHyperlink() — not public API.
|
|
// Availability of Qt::GuiPrivate is verified at configure time in CMakeLists.txt.
|
|
#include <private/qpdf_p.h>
|
|
|
|
#include <QByteArray>
|
|
#include <QFile>
|
|
#include <QGraphicsTextItem>
|
|
#include <QList>
|
|
#include <QRegularExpression>
|
|
#include <QUrl>
|
|
#include <QVector>
|
|
|
|
#include <algorithm>
|
|
|
|
namespace PdfLinks {
|
|
|
|
void injectCrossRefLinks(QPdfEngine *engine, Diagram *diagram,
|
|
const PageGeometry &geom,
|
|
const QMap<Diagram *, int> &pageMap,
|
|
const QString &outputFileName)
|
|
{
|
|
if (!engine || !diagram)
|
|
return;
|
|
|
|
const QTransform &fit = geom.sceneToDevice;
|
|
const QRectF &target = geom.target;
|
|
const QRectF &pageBounds = geom.pageBounds;
|
|
|
|
// Compute, in PDF points on its OWN page, the rectangle to frame for a
|
|
// target element (used as a /FitR destination so the link zooms onto it).
|
|
auto destRectPdf = [&](Element *tgt) -> QRectF {
|
|
Diagram *dg = tgt ? tgt->diagram() : nullptr;
|
|
if (!dg) return QRectF();
|
|
const QRectF srcT = geom.sourceRectOf(dg);
|
|
if (srcT.width() <= 0.0 || srcT.height() <= 0.0) return QRectF();
|
|
const qreal sT = qMin(target.width() / srcT.width(),
|
|
target.height() / srcT.height());
|
|
QTransform fitT;
|
|
fitT.translate(target.x(), target.y());
|
|
fitT.scale(sT, sT);
|
|
fitT.translate(-srcT.x(), -srcT.y());
|
|
|
|
QRectF elemScene = tgt->mapRectToScene(tgt->boundingRect());
|
|
// Frame the element with a little context, and enforce a minimum
|
|
// framed size so tiny contacts don't zoom in extremely.
|
|
const qreal pad = 25.0;
|
|
elemScene.adjust(-pad, -pad, pad, pad);
|
|
const qreal minSide = 160.0;
|
|
if (elemScene.width() < minSide)
|
|
elemScene.adjust(-(minSide - elemScene.width()) / 2.0, 0,
|
|
(minSide - elemScene.width()) / 2.0, 0);
|
|
if (elemScene.height() < minSide)
|
|
elemScene.adjust(0, -(minSide - elemScene.height()) / 2.0,
|
|
0, (minSide - elemScene.height()) / 2.0);
|
|
|
|
const QRectF devT = fitT.mapRect(elemScene);
|
|
const QPointF a = geom.devToPdf(devT.topLeft());
|
|
const QPointF b = geom.devToPdf(devT.bottomRight());
|
|
return QRectF(QPointF(qMin(a.x(), b.x()), qMin(a.y(), b.y())),
|
|
QPointF(qMax(a.x(), b.x()), qMax(a.y(), b.y())));
|
|
};
|
|
|
|
auto injectLink = [&](const QRectF &sceneRect, Element *targetElmt) {
|
|
if (!targetElmt || !targetElmt->diagram()) return;
|
|
const int targetPage = pageMap.value(targetElmt->diagram(), -1);
|
|
if (targetPage < 1) return;
|
|
const QRectF devRect = fit.mapRect(sceneRect);
|
|
if (!devRect.isValid() || !pageBounds.intersects(devRect)) return;
|
|
|
|
QString frag = QString("page=%1").arg(targetPage);
|
|
const QRectF d = destRectPdf(targetElmt); // /FitR L_B_R_T
|
|
if (d.isValid())
|
|
frag += QString("&fitr=%1_%2_%3_%4")
|
|
.arg(qRound(d.left())).arg(qRound(d.top()))
|
|
.arg(qRound(d.right())).arg(qRound(d.bottom()));
|
|
|
|
QUrl url = QUrl::fromLocalFile(outputFileName);
|
|
url.setFragment(frag);
|
|
engine->drawHyperlink(devRect, url);
|
|
};
|
|
|
|
for (auto *item : diagram->items()) {
|
|
|
|
// --- CrossRefItem links ---
|
|
if (auto *xref = dynamic_cast<CrossRefItem*>(item)) {
|
|
for (auto it = xref->hoveredContactsMap().begin();
|
|
it != xref->hoveredContactsMap().end(); ++it)
|
|
{
|
|
Element *targetElmt = it.key();
|
|
if (!targetElmt || !targetElmt->diagram()) continue;
|
|
// it.value() is in the CrossRefItem's LOCAL coords -> scene
|
|
injectLink(xref->mapRectToScene(it.value()), targetElmt);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// --- Folio report links (DynamicElementTextItem) ---
|
|
if (auto *deti = dynamic_cast<DynamicElementTextItem*>(item)) {
|
|
Element *parent = deti->parentElement();
|
|
if (!parent) continue;
|
|
|
|
// (a) Report element : label -> linked report on another folio
|
|
if (parent->linkType() & Element::AllReport) {
|
|
if (parent->linkedElements().isEmpty()) continue;
|
|
|
|
bool showsLabel =
|
|
(deti->textFrom() == DynamicElementTextItem::ElementInfo
|
|
&& deti->infoName() == QLatin1String("label")) ||
|
|
(deti->textFrom() == DynamicElementTextItem::CompositeText
|
|
&& deti->compositeText().contains(QStringLiteral("%{label}")));
|
|
if (!showsLabel) continue;
|
|
|
|
Element *targetElmt = parent->linkedElements().first();
|
|
if (!targetElmt || !targetElmt->diagram()) continue;
|
|
|
|
injectLink(deti->mapRectToScene(deti->boundingRect()), targetElmt);
|
|
continue;
|
|
}
|
|
|
|
// (b) Slave element : the "(folio-pos)" text -> master element
|
|
if (parent->linkType() == Element::Slave) {
|
|
QGraphicsTextItem *sx = deti->slaveXrefItem();
|
|
Element *master = deti->masterElement();
|
|
if (sx && master && master->diagram()) {
|
|
injectLink(sx->mapRectToScene(sx->boundingRect()), master);
|
|
}
|
|
continue;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// --- Slave cross-reference carried by a grouped text ---
|
|
if (auto *grp = dynamic_cast<ElementTextItemGroup*>(item)) {
|
|
Element *parent = grp->parentElement();
|
|
if (!parent || parent->linkType() != Element::Slave) continue;
|
|
if (parent->linkedElements().isEmpty()) continue;
|
|
QGraphicsTextItem *sx = grp->slaveXrefItem();
|
|
if (!sx) continue;
|
|
Element *master = parent->linkedElements().first();
|
|
if (!master || !master->diagram()) continue;
|
|
injectLink(sx->mapRectToScene(sx->boundingRect()), master);
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
void convertUriToGoTo(const QString &pdfPath)
|
|
{
|
|
// --- 1. Read raw bytes ---
|
|
QFile f(pdfPath);
|
|
if (!f.open(QIODevice::ReadOnly)) return;
|
|
QByteArray data = f.readAll();
|
|
f.close();
|
|
|
|
// --- 2. Collect page object numbers in document order ---
|
|
// Read them from the page tree (/Type /Pages -> /Kids [ N 0 R ... ]).
|
|
// This is reliable; scanning raw bytes for "/Type /Page" is NOT: that
|
|
// marker also occurs inside content streams, and a forward lookahead
|
|
// wrongly tags neighbouring objects (it found 280 "pages" for a 137-page
|
|
// document). Qt writes a single, flat /Kids array listing every page.
|
|
QVector<int> pageObjs;
|
|
{
|
|
int pagesPos = data.indexOf("/Type /Pages");
|
|
int kidsPos = (pagesPos == -1) ? -1 : data.indexOf("/Kids", pagesPos);
|
|
int lb = (kidsPos == -1) ? -1 : data.indexOf('[', kidsPos);
|
|
int rb = (lb == -1) ? -1 : data.indexOf(']', lb);
|
|
if (lb != -1 && rb != -1 && rb > lb) {
|
|
const QString kids =
|
|
QString::fromLatin1(data.mid(lb + 1, rb - lb - 1));
|
|
QRegularExpression re(QStringLiteral("(\\d+)\\s+\\d+\\s+R"));
|
|
auto it = re.globalMatch(kids);
|
|
while (it.hasNext()) {
|
|
int objNum = it.next().captured(1).toInt();
|
|
if (objNum > 0) pageObjs.append(objNum);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (pageObjs.isEmpty()) return; // nothing to do
|
|
|
|
// --- 3. Replace URI annotations with GoTo ---
|
|
// Pattern (Qt always writes exactly this):
|
|
// /S /URI\n/URI (file:///...<anything>#page=N)\n
|
|
// or (older patches without file://):
|
|
// /S /URI\n/URI (page=N)\n
|
|
bool changed = false;
|
|
{
|
|
// We do a manual scan to handle variable-length replacements.
|
|
QByteArray out;
|
|
out.reserve(data.size());
|
|
|
|
const QByteArray sUri = "/S /URI\n/URI (";
|
|
const QByteArray sGoTo = "/S /GoTo\n/D [";
|
|
int pos = 0;
|
|
|
|
while (pos < data.size()) {
|
|
int found = data.indexOf(sUri, pos);
|
|
if (found == -1) {
|
|
out.append(data.mid(pos));
|
|
break;
|
|
}
|
|
|
|
// Copy everything up to the match
|
|
out.append(data.mid(pos, found - pos));
|
|
|
|
// Find closing ')' of the URI value
|
|
int uriStart = found + sUri.size();
|
|
int closeParen = data.indexOf(")\n", uriStart);
|
|
if (closeParen == -1) {
|
|
// Malformed — copy rest verbatim
|
|
out.append(data.mid(found));
|
|
pos = data.size();
|
|
break;
|
|
}
|
|
|
|
QByteArray uriVal = data.mid(uriStart, closeParen - uriStart);
|
|
|
|
// Skip component-info annotations — handled by
|
|
// convertComponentInfoAnnotations() in a separate pass.
|
|
if (uriVal.startsWith("componentinfo://")
|
|
|| uriVal.startsWith("http://componentinfo.local/")) {
|
|
out.append(data.mid(found, closeParen + 1 - found));
|
|
pos = closeParen + 1;
|
|
continue;
|
|
}
|
|
|
|
// Extract page number: look for #page=N or bare page=N
|
|
int pageNum = -1;
|
|
int hashPos = uriVal.lastIndexOf("#page=");
|
|
int digitStart = -1;
|
|
if (hashPos != -1) {
|
|
digitStart = hashPos + 6;
|
|
} else if (uriVal.startsWith("page=")) {
|
|
digitStart = 5;
|
|
}
|
|
if (digitStart != -1) {
|
|
// Take only the leading digits: the fragment may carry extra
|
|
// parameters after the page number (e.g. "22&fitr=15_489_..."),
|
|
// and QByteArray::toInt() would fail on the whole remainder.
|
|
int e = digitStart;
|
|
while (e < uriVal.size()
|
|
&& uriVal[e] >= '0' && uriVal[e] <= '9')
|
|
++e;
|
|
if (e > digitStart)
|
|
pageNum = uriVal.mid(digitStart, e - digitStart).toInt();
|
|
}
|
|
|
|
if (pageNum >= 1 && pageNum <= pageObjs.size()) {
|
|
// Valid page reference — emit GoTo action.
|
|
int pageObjNum = pageObjs[pageNum - 1];
|
|
|
|
// Optional precise destination: &fitr=Left_Bottom_Right_Top
|
|
// (integer PDF points). If present -> /FitR (frame the element);
|
|
// otherwise -> /Fit (whole page, top).
|
|
QByteArray dest = " /Fit]";
|
|
int fr = uriVal.indexOf("fitr=");
|
|
if (fr != -1) {
|
|
QByteArray rest = uriVal.mid(fr + 5);
|
|
// stop at first char that is not part of the number list
|
|
int end = 0;
|
|
while (end < rest.size()
|
|
&& ((rest[end] >= '0' && rest[end] <= '9')
|
|
|| rest[end] == '_' || rest[end] == '-'))
|
|
++end;
|
|
QList<QByteArray> parts = rest.left(end).split('_');
|
|
if (parts.size() == 4) {
|
|
dest = " /FitR " + parts[0] + " " + parts[1] + " "
|
|
+ parts[2] + " " + parts[3] + "]";
|
|
}
|
|
}
|
|
|
|
QByteArray goTo = sGoTo
|
|
+ QByteArray::number(pageObjNum)
|
|
+ " 0 R" + dest;
|
|
out.append(goTo);
|
|
changed = true;
|
|
} else {
|
|
// Unknown page — keep original URI
|
|
out.append(sUri);
|
|
out.append(uriVal);
|
|
out.append(')');
|
|
}
|
|
|
|
pos = closeParen + 1; // skip past ')'
|
|
}
|
|
|
|
if (!changed) return; // nothing was replaced
|
|
data = out;
|
|
}
|
|
|
|
// --- 4. Rebuild xref table ---
|
|
// Find start of existing xref (last occurrence)
|
|
int xrefStart = data.lastIndexOf("\nxref\n");
|
|
if (xrefStart == -1) xrefStart = data.lastIndexOf("\nxref ");
|
|
if (xrefStart == -1) return; // malformed PDF
|
|
++xrefStart; // skip the leading '\n'
|
|
|
|
QByteArray body = data.left(xrefStart);
|
|
|
|
// Collect all object offsets from the body
|
|
QMap<int, int> offsets; // objNum -> byte offset
|
|
{
|
|
const QByteArray objMarker = " 0 obj";
|
|
int pos = 0;
|
|
while ((pos = body.indexOf(objMarker, pos)) != -1) {
|
|
int numStart = pos - 1;
|
|
while (numStart > 0 && body[numStart-1] != '\n' && body[numStart-1] != '\r')
|
|
--numStart;
|
|
QByteArray numStr = body.mid(numStart, pos - numStart).trimmed();
|
|
bool ok = false;
|
|
int objNum = numStr.toInt(&ok);
|
|
if (ok && objNum > 0)
|
|
offsets[objNum] = numStart;
|
|
++pos;
|
|
}
|
|
}
|
|
|
|
if (offsets.isEmpty()) return;
|
|
|
|
int maxObj = offsets.lastKey();
|
|
|
|
// Build xref table
|
|
QByteArray xref;
|
|
xref += "xref\n";
|
|
xref += "0 " + QByteArray::number(maxObj + 1) + "\n";
|
|
xref += "0000000000 65535 f \n";
|
|
for (int i = 1; i <= maxObj; ++i) {
|
|
if (offsets.contains(i)) {
|
|
xref += QByteArray::number(offsets[i]).rightJustified(10, '0')
|
|
+ " 00000 n \n";
|
|
} else {
|
|
xref += "0000000000 65535 f \n";
|
|
}
|
|
}
|
|
|
|
// Find trailer dict from the original xref section
|
|
int trailerPos = data.indexOf("trailer", xrefStart);
|
|
int trailerEnd = -1;
|
|
if (trailerPos != -1) {
|
|
trailerEnd = data.indexOf("%%EOF", trailerPos);
|
|
if (trailerEnd != -1) trailerEnd += 5;
|
|
}
|
|
|
|
QByteArray trailer;
|
|
if (trailerPos != -1 && trailerEnd != -1)
|
|
trailer = data.mid(trailerPos, trailerEnd - trailerPos);
|
|
else
|
|
trailer = "trailer\n<<>>\n%%EOF";
|
|
|
|
int newXrefOffset = body.size();
|
|
|
|
QByteArray result;
|
|
result.reserve(body.size() + xref.size() + trailer.size() + 30);
|
|
result += body;
|
|
result += xref;
|
|
result += trailer;
|
|
result += "\nstartxref\n";
|
|
result += QByteArray::number(newXrefOffset);
|
|
result += "\n%%EOF\n";
|
|
|
|
// --- 5. Write back ---
|
|
QFile out(pdfPath);
|
|
if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) return;
|
|
out.write(result);
|
|
out.close();
|
|
}
|
|
|
|
void removeUnusedPdfxNamespace(const QString &pdfPath)
|
|
{
|
|
QFile f(pdfPath);
|
|
if (!f.open(QIODevice::ReadOnly)) return;
|
|
QByteArray data = f.readAll();
|
|
f.close();
|
|
|
|
// Qt only fills the pdfxid namespace in when it writes PDF/X-4.
|
|
if (data.contains("pdfxid:GTS_PDFXVersion")) return;
|
|
|
|
static const QByteArray decl =
|
|
" xmlns:pdfxid=\"http://www.npes.org/pdfx/ns/id/\"";
|
|
const int pos = data.indexOf(decl);
|
|
if (pos == -1) return;
|
|
|
|
// Same length, so the stream /Length and the xref offsets stay valid.
|
|
data.replace(pos, decl.size(), QByteArray(decl.size(), ' '));
|
|
|
|
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) return;
|
|
f.write(data);
|
|
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, 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) {
|
|
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 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) {
|
|
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)
|
|
{
|
|
if (annotations.isEmpty()) return;
|
|
|
|
QFile f(pdfPath);
|
|
if (!f.open(QIODevice::ReadOnly)) return;
|
|
QByteArray data = f.readAll();
|
|
f.close();
|
|
|
|
const QByteArray markerPrefix("http://componentinfo.local/");
|
|
if (!data.contains(markerPrefix)) return;
|
|
|
|
int xrefStart = data.lastIndexOf("\nxref\n");
|
|
if (xrefStart == -1) xrefStart = data.lastIndexOf("\nxref ");
|
|
if (xrefStart == -1) return;
|
|
++xrefStart;
|
|
QByteArray body = data.left(xrefStart);
|
|
|
|
// Pre-scan body for highest object number
|
|
int maxObjNum = 0;
|
|
{
|
|
const QByteArray objMarker(" 0 obj");
|
|
int p = 0;
|
|
while ((p = body.indexOf(objMarker, p)) != -1) {
|
|
int numStart = p - 1;
|
|
while (numStart > 0 && body[numStart - 1] != '\n' && body[numStart - 1] != '\r')
|
|
--numStart;
|
|
QByteArray numStr = body.mid(numStart, p - numStart).trimmed();
|
|
bool ok = false;
|
|
int num = numStr.toInt(&ok);
|
|
if (ok && num > maxObjNum)
|
|
maxObjNum = num;
|
|
++p;
|
|
}
|
|
}
|
|
// Empty Form XObject used as invisible appearance for all annotations.
|
|
// All annotations reference this single object.
|
|
int emptyXObjNum = maxObjNum + 1;
|
|
|
|
QByteArray out;
|
|
out.reserve(data.size());
|
|
|
|
int pos = 0;
|
|
bool anyConverted = false;
|
|
|
|
while (pos < body.size()) {
|
|
// Find next indexed marker: http://componentinfo.local/<N>
|
|
int markerPos = body.indexOf(markerPrefix, pos);
|
|
if (markerPos == -1) {
|
|
out.append(body.mid(pos));
|
|
break;
|
|
}
|
|
|
|
// Extract index from marker URL
|
|
int idxStart = markerPos + markerPrefix.size();
|
|
int idxEnd = idxStart;
|
|
while (idxEnd < body.size() && body[idxEnd] >= '0' && body[idxEnd] <= '9')
|
|
++idxEnd;
|
|
if (idxEnd == idxStart) {
|
|
// No index — skip malformed marker
|
|
out.append(body.mid(pos, markerPos + markerPrefix.size() - pos));
|
|
pos = markerPos + markerPrefix.size();
|
|
continue;
|
|
}
|
|
int annotIndex = body.mid(idxStart, idxEnd - idxStart).toInt();
|
|
|
|
int uriOpen = body.lastIndexOf("/URI (", markerPos);
|
|
int closeParen = (uriOpen != -1) ? body.indexOf(')', markerPos) : -1;
|
|
if (uriOpen == -1 || closeParen == -1 || uriOpen < pos) {
|
|
out.append(body.mid(pos, idxEnd - pos));
|
|
pos = idxEnd;
|
|
continue;
|
|
}
|
|
|
|
// Find /S /URI before /URI (
|
|
int sUriPos = body.lastIndexOf("/S /URI", uriOpen);
|
|
|
|
// Find the /A << that opens the action dict containing /S /URI.
|
|
// Qt always writes: /A <<\n/S /URI\n/URI (...)\n>>\n>>
|
|
int aDictOpen = (sUriPos != -1)
|
|
? body.lastIndexOf("/A <<", sUriPos)
|
|
: -1;
|
|
|
|
// If /A << is not found, leave this annotation untouched
|
|
if (aDictOpen == -1 || aDictOpen < pos) {
|
|
out.append(body.mid(pos, closeParen + 1 - pos));
|
|
pos = closeParen + 1;
|
|
continue;
|
|
}
|
|
|
|
// Validate index
|
|
if (annotIndex < 0 || annotIndex >= annotations.size()) {
|
|
// Index out of range — keep original annotation intact
|
|
out.append(body.mid(pos, closeParen + 1 - pos));
|
|
pos = closeParen + 1;
|
|
continue;
|
|
}
|
|
|
|
// Copy annotation dict header up to /A <<
|
|
out.append(body.mid(pos, aDictOpen - pos));
|
|
|
|
QByteArray contents = annotations[annotIndex].contents.toUtf8();
|
|
|
|
// Replace /Subtype /Link with /Subtype /Text, bounded to the enclosing
|
|
// PDF object. Anchoring to " 0 obj" prevents hitting /Subtype /Link
|
|
// from a cross-ref annotation that sits between the previous marker
|
|
// and the current annotation on the same page.
|
|
int objStart = body.lastIndexOf(" 0 obj", aDictOpen);
|
|
int subTypeInBody = (objStart != -1)
|
|
? body.indexOf("/Subtype /Link", objStart) : -1;
|
|
int subTypePos = (subTypeInBody != -1 && subTypeInBody < aDictOpen)
|
|
? out.size() - (aDictOpen - subTypeInBody) : -1;
|
|
if (subTypePos != -1)
|
|
// Acrobat draws its own sticky-note icon for /Subtype /Text
|
|
// whatever /AP says (verified on Reader 5.0). /Square with no
|
|
// /C and no /IC draws nothing anywhere, and is still a markup
|
|
// annotation, so the /Contents popup is unaffected.
|
|
out.replace(subTypePos, 14, "/Subtype /Square");
|
|
|
|
// Encode as UTF-16BE hex with BOM for proper Unicode support (Umlauten etc.).
|
|
// PDF spec: hex strings starting with FE FF are interpreted as UTF-16BE.
|
|
QByteArray utf16be;
|
|
utf16be.append('\xfe');
|
|
utf16be.append('\xff');
|
|
{
|
|
for (int i = 0; i < contents.size(); ) {
|
|
ushort cp = 0;
|
|
uchar c = static_cast<uchar>(contents.at(i));
|
|
if (c < 0x80) {
|
|
cp = c;
|
|
++i;
|
|
} else if ((c & 0xE0) == 0xC0 && i + 1 < contents.size()) {
|
|
cp = ((c & 0x1F) << 6)
|
|
| (static_cast<uchar>(contents.at(i + 1)) & 0x3F);
|
|
i += 2;
|
|
} else if ((c & 0xF0) == 0xE0 && i + 2 < contents.size()) {
|
|
cp = ((c & 0x0F) << 12)
|
|
| ((static_cast<uchar>(contents.at(i + 1)) & 0x3F) << 6)
|
|
| (static_cast<uchar>(contents.at(i + 2)) & 0x3F);
|
|
i += 3;
|
|
} else {
|
|
cp = 0xFFFD; // replacement character
|
|
++i;
|
|
}
|
|
utf16be.append(static_cast<char>((cp >> 8) & 0xFF));
|
|
utf16be.append(static_cast<char>(cp & 0xFF));
|
|
}
|
|
}
|
|
|
|
out += "/Contents <";
|
|
out += utf16be.toHex();
|
|
out += ">\n/AP << /N " + QByteArray::number(emptyXObjNum) + " 0 R >>\n";
|
|
|
|
// Skip the entire /A << ... >> action dict.
|
|
// After closeParen ')' we have: \n>> (closes /A dict) \n>> (closes annot dict)
|
|
int actionDictClose = body.indexOf(">>", closeParen + 1);
|
|
if (actionDictClose != -1) {
|
|
pos = actionDictClose + 2; // skip past >> that closes /A dict
|
|
} else {
|
|
pos = closeParen + 1;
|
|
}
|
|
anyConverted = true;
|
|
}
|
|
|
|
if (!anyConverted) return;
|
|
|
|
// Append empty Form XObject (shared by all annotations for invisible appearance)
|
|
QByteArray emptyXObj;
|
|
emptyXObj += QByteArray::number(emptyXObjNum) + " 0 obj\n";
|
|
emptyXObj += "<< /Type /XObject /Subtype /Form /BBox [0 0 0 0] /Length 0 >>\n";
|
|
emptyXObj += "stream\nendstream\n";
|
|
emptyXObj += "endobj\n";
|
|
out += emptyXObj;
|
|
|
|
// Rebuild xref table
|
|
QMap<int, int> offsets;
|
|
{
|
|
const QByteArray objMarker(" 0 obj");
|
|
int p = 0;
|
|
while ((p = out.indexOf(objMarker, p)) != -1) {
|
|
int numStart = p - 1;
|
|
while (numStart > 0 && out[numStart - 1] != '\n' && out[numStart - 1] != '\r')
|
|
--numStart;
|
|
QByteArray numStr = out.mid(numStart, p - numStart).trimmed();
|
|
bool ok = false;
|
|
int objNum = numStr.toInt(&ok);
|
|
if (ok && objNum > 0)
|
|
offsets[objNum] = numStart;
|
|
++p;
|
|
}
|
|
}
|
|
|
|
if (offsets.isEmpty()) return;
|
|
|
|
int maxObj = offsets.lastKey();
|
|
|
|
QByteArray xref;
|
|
xref += "xref\n";
|
|
xref += "0 " + QByteArray::number(maxObj + 1) + "\n";
|
|
xref += "0000000000 65535 f \n";
|
|
for (int i = 1; i <= maxObj; ++i) {
|
|
if (offsets.contains(i)) {
|
|
xref += QByteArray::number(offsets[i]).rightJustified(10, '0')
|
|
+ " 00000 n \n";
|
|
} else {
|
|
xref += "0000000000 65535 f \n";
|
|
}
|
|
}
|
|
|
|
// Copy trailer and bump /Size to account for the new XObject
|
|
QByteArray trailer;
|
|
{
|
|
int tPos = data.indexOf("trailer", xrefStart);
|
|
if (tPos != -1) {
|
|
int tEnd = data.indexOf("%%EOF", tPos);
|
|
if (tEnd != -1) tEnd += 5;
|
|
if (tEnd != -1)
|
|
trailer = data.mid(tPos, tEnd - tPos);
|
|
}
|
|
}
|
|
if (trailer.isEmpty())
|
|
trailer = "trailer\n<<>>\n%%EOF";
|
|
|
|
// Bump /Size: original was maxObjNum+1, now it's emptyXObjNum+1
|
|
{
|
|
int sizePos = trailer.indexOf("/Size ");
|
|
if (sizePos != -1) {
|
|
int numStart = sizePos + 6;
|
|
int numEnd = numStart;
|
|
while (numEnd < trailer.size() && trailer[numEnd] >= '0' && trailer[numEnd] <= '9')
|
|
++numEnd;
|
|
if (numEnd > numStart) {
|
|
trailer.replace(numStart, numEnd - numStart,
|
|
QByteArray::number(emptyXObjNum + 1));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove duplicate startxref if present in copied trailer
|
|
{
|
|
int stPos = trailer.indexOf("\nstartxref\n");
|
|
if (stPos != -1)
|
|
trailer = trailer.left(stPos);
|
|
// Ensure trailer ends with %%EOF
|
|
if (!trailer.endsWith("%%EOF\n"))
|
|
trailer += "\n%%EOF\n";
|
|
}
|
|
|
|
QByteArray result;
|
|
result.reserve(out.size() + xref.size() + trailer.size() + 64);
|
|
result += out;
|
|
int newXrefOffset = out.size();
|
|
result += xref;
|
|
result += trailer;
|
|
result += "\nstartxref\n";
|
|
result += QByteArray::number(newXrefOffset);
|
|
result += "\n%%EOF\n";
|
|
|
|
QFile outF(pdfPath);
|
|
if (!outF.open(QIODevice::WriteOnly | QIODevice::Truncate)) return;
|
|
outF.write(result);
|
|
outF.close();
|
|
}
|
|
|
|
} // namespace PdfLinks
|