mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-06-11 21:53:14 +02:00
PDF links: extract pdf_links.{cpp,h} shared helper
Move the PDF cross-reference hyperlink logic out of ProjectPrintWindow into a standalone translation unit so it can be reused (the CLI PDF export will call it next): - injectCrossRefLinks(): emits the URI link annotations for a diagram's cross-references and folio reports. The scene-to-page mapping is passed in as a PageGeometry (transform + devToPdf + source-rect lookup) so each caller supplies its own correct geometry, rather than the helper assuming a QPrinter. - convertUriToGoTo(): the PDF post-processor, moved verbatim. ProjectPrintWindow stays a pure caller: it builds its PageGeometry from the printer page layout exactly as before and calls the helper. No behavioural change to GUI PDF export; no class-structure changes. Per review guidance on #483.
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
/*
|
||||
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, stable since Qt4.
|
||||
// Requires QT += gui-private in qelectrotech.pro / gui-private in CMake.
|
||||
#include <private/qpdf_p.h>
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QFile>
|
||||
#include <QGraphicsTextItem>
|
||||
#include <QList>
|
||||
#include <QRegularExpression>
|
||||
#include <QUrl>
|
||||
#include <QVector>
|
||||
|
||||
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(')', uriStart);
|
||||
if (closeParen == -1) {
|
||||
// Malformed — copy rest verbatim
|
||||
out.append(data.mid(found));
|
||||
pos = data.size();
|
||||
break;
|
||||
}
|
||||
|
||||
QByteArray uriVal = data.mid(uriStart, closeParen - uriStart);
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
} // namespace PdfLinks
|
||||
Reference in New Issue
Block a user