mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-10-11 14:44:15 +02:00
bf9b34398d
Cables are drawn line objects (toolbar button next to the auto break/reconnect button, click-move-click, right click cancels), not components. The type comes from a CSV catalog following the material list pattern (designation, cores, core colours, editable through the entry dialog with "Nouvelle entrée" / "Modifier l'entrée"). Each core is bound to the wire its colour label stands on, and the cable reference is generated per core and written into the Conductor entry via Conductor::setCableReference(label, cable uuid, slot). The conductor entry is where it has to live because the terminal strip plan (Klemmenplan) consumes it in the next step. Numbering: "Cables" rule under Programmeinstellungen/Neues Projekt/Nummerierung auto (mirrored in project properties), literal "W" fallback without a rule, French gate dialog when no rule exists, Nummerierung menu with the renumber dialog (preferred axis, per-folio counter, rule and counter state written back, hand-typed names ask, whole-project scope). Also included: label block and under-line texts with per-text font and alignment plus the material-list cell margins, free-core placement and right-click removal, claim questions for wires of other cables, multi-selection drag as one undo step, type change with confirmation, cross-references for type "Cable" (%f-%l%c, own font, clickable links in exported PDFs), report propagation of the cable definition, Listes menu (table of contents, material list, terminal strip manager, terminal generator plugin, cable list), cable list with freely selectable columns (installation/localisation of cable, start and end, Blatt start/end, length, used cores...), CSV export dialog with column pick and preview in the Projekt menu, script API (addTable kind "cable_list", exportCableList) and a unit test for the type catalog.
702 lines
22 KiB
C++
702 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 "cable/cablepart.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>
|
|
|
|
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 (used as a /FitR destination so the link zooms onto it).
|
|
// The target is given as a rectangle in the scene of @p dg, since what
|
|
// is pointed at is not always an element: a cable section is a plain
|
|
// drawn line somewhere on another folio.
|
|
auto frameRectPdf = [&](Diagram *dg, const QRectF &targetScene) -> QRectF {
|
|
if (!dg || targetScene.width() <= 0.0 || targetScene.height() <= 0.0)
|
|
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());
|
|
|
|
// Frame the target with a little context, and enforce a minimum
|
|
// framed size so tiny contacts don't zoom in extremely.
|
|
QRectF framed = targetScene;
|
|
const qreal pad = 25.0;
|
|
framed.adjust(-pad, -pad, pad, pad);
|
|
const qreal minSide = 160.0;
|
|
if (framed.width() < minSide)
|
|
framed.adjust(-(minSide - framed.width()) / 2.0, 0,
|
|
(minSide - framed.width()) / 2.0, 0);
|
|
if (framed.height() < minSide)
|
|
framed.adjust(0, -(minSide - framed.height()) / 2.0,
|
|
0, (minSide - framed.height()) / 2.0);
|
|
|
|
const QRectF devT = fitT.mapRect(framed);
|
|
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())));
|
|
};
|
|
|
|
// One clickable link: @p sceneRect on THIS folio leads to
|
|
// @p targetDiagram, framed around @p targetScene over there.
|
|
auto injectLinkTo = [&](const QRectF &sceneRect, Diagram *targetDiagram,
|
|
const QRectF &targetScene) {
|
|
if (!targetDiagram) return;
|
|
const int targetPage = pageMap.value(targetDiagram, -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 = frameRectPdf(targetDiagram, targetScene); // /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);
|
|
};
|
|
|
|
auto injectLink = [&](const QRectF &sceneRect, Element *targetElmt) {
|
|
if (!targetElmt || !targetElmt->diagram()) return;
|
|
injectLinkTo(sceneRect, targetElmt->diagram(),
|
|
targetElmt->mapRectToScene(targetElmt->boundingRect()));
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
// --- Cable cross-reference links (the lines under a cable which
|
|
// name the folio it goes on to) ---
|
|
if (auto *cable = dynamic_cast<CablePart*>(item)) {
|
|
for (const CablePart::PdfRef &ref : cable->pdfRefs()) {
|
|
injectLinkTo(ref.here, ref.diagram, ref.there);
|
|
}
|
|
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 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
|