mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-10-03 17:34:12 +02:00
8e6dc1a849
injectCrossRefLinks() drew each link as it found it: the contacts of a cross-reference come from a QMultiMap keyed by Element pointer, and the scene's items in stacking order. Both change from run to run, so the same project exported twice gave two different PDFs whose only difference was the order of the link annotations. The links are now collected and drawn sorted by their rectangle on the page (top, left, bottom, right), with the target as the tie-break. The links themselves are unchanged. The print window's PDF export goes through the same function. tst_pdflinkorder exports examples/industrial.qet five times in separate processes and requires the same links in the same order. It fails on the old code (lists differ at index 2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
706 lines
22 KiB
C++
706 lines
22 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())));
|
|
};
|
|
|
|
// The links are collected first and drawn sorted, not in the order the
|
|
// loop below finds them: a cross-reference's contacts are keyed by
|
|
// Element pointer and the scene's items come in stacking order, so that
|
|
// order changes from run to run and the same project gave a different
|
|
// PDF each time it was exported.
|
|
struct Link { QRectF rect; QUrl url; };
|
|
QList<Link> links;
|
|
|
|
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);
|
|
links.append({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;
|
|
}
|
|
}
|
|
|
|
// Top to bottom, then left to right; the target breaks a tie between
|
|
// two links on the same spot.
|
|
std::sort(links.begin(), links.end(), [](const Link &a, const Link &b) {
|
|
if (a.rect.top() != b.rect.top())
|
|
return a.rect.top() < b.rect.top();
|
|
if (a.rect.left() != b.rect.left())
|
|
return a.rect.left() < b.rect.left();
|
|
if (a.rect.bottom() != b.rect.bottom())
|
|
return a.rect.bottom() < b.rect.bottom();
|
|
if (a.rect.right() != b.rect.right())
|
|
return a.rect.right() < b.rect.right();
|
|
return a.url.fragment() < b.url.fragment();
|
|
});
|
|
for (const Link &link : std::as_const(links))
|
|
engine->drawHyperlink(link.rect, link.url);
|
|
}
|
|
|
|
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 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
|