Export component info as invisible PDF text annotations

This commit is contained in:
Kellermorph
2026-08-13 11:54:33 +02:00
parent 10cc162c4e
commit 67b1665b36
5 changed files with 301 additions and 20 deletions
+206
View File
@@ -32,6 +32,7 @@
#include <QGraphicsTextItem> #include <QGraphicsTextItem>
#include <QList> #include <QList>
#include <QRegularExpression> #include <QRegularExpression>
#include <QTextCodec>
#include <QUrl> #include <QUrl>
#include <QVector> #include <QVector>
@@ -238,6 +239,15 @@ void convertUriToGoTo(const QString &pdfPath)
QByteArray uriVal = data.mid(uriStart, closeParen - uriStart); 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 // Extract page number: look for #page=N or bare page=N
int pageNum = -1; int pageNum = -1;
int hashPos = uriVal.lastIndexOf("#page="); int hashPos = uriVal.lastIndexOf("#page=");
@@ -379,4 +389,200 @@ void convertUriToGoTo(const QString &pdfPath)
out.close(); out.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 marker("http://componentinfo.local/");
if (!data.contains(marker)) 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;
int annotIdx = 0;
while (pos < body.size()) {
int markerPos = body.indexOf(marker, pos);
if (markerPos == -1) {
out.append(body.mid(pos));
break;
}
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, markerPos + marker.size() - pos));
pos = markerPos + marker.size();
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>>
// We need /Contents at the annotation level, NOT inside /A <<.
int aDictOpen = body.lastIndexOf("/A <<", (sUriPos != -1) ? sUriPos : uriOpen);
int copyEnd = (aDictOpen != -1 && aDictOpen >= pos) ? aDictOpen
: (sUriPos != -1 && sUriPos >= pos) ? sUriPos
: uriOpen;
out.append(body.mid(pos, copyEnd - pos));
// Get text from annotations list (matched by order)
if (annotIdx >= annotations.size()) {
// Keep original action dict intact
out.append(body.mid(copyEnd, closeParen + 1 - copyEnd));
pos = closeParen + 1;
continue;
}
QByteArray contents = annotations[annotIdx].contents.toUtf8();
++annotIdx;
// Replace /Subtype /Link with /Subtype /Text
int subTypePos = out.lastIndexOf("/Subtype /Link");
if (subTypePos != -1) {
out.replace(subTypePos, 14, "/Subtype /Text");
}
// 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.
QTextCodec *codec = QTextCodec::codecForName("UTF-16BE");
QByteArray utf16be;
utf16be.append('\xfe');
utf16be.append('\xff');
if (codec) {
utf16be += codec->fromUnicode(QString::fromUtf8(contents));
} else {
// Fallback: manual UTF-16BE encoding
QString str = QString::fromUtf8(contents);
for (int i = 0; i < str.size(); ++i) {
ushort cp = str.at(i).unicode();
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;
}
}
if (annotIdx == 0) 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";
}
}
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";
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 } // namespace PdfLinks
+14 -7
View File
@@ -66,13 +66,20 @@ namespace PdfLinks {
const QMap<Diagram *, int> &pageMap, const QMap<Diagram *, int> &pageMap,
const QString &outputFileName); const QString &outputFileName);
/** /**
Post-process a Qt-generated PDF file: rewrite every "/S /URI" link Post-process a Qt-generated PDF file: rewrite every "/S /URI" link
annotation into a native internal "/S /GoTo" action (page + /FitR or annotation into a native internal "/S /GoTo" action (page + /FitR or
/Fit destination) and rebuild the xref table. No-op if the file has no /Fit destination) and rebuild the xref table. No-op if the file has no
such annotations. such annotations.
*/ */
void convertUriToGoTo(const QString &pdfPath); void convertUriToGoTo(const QString &pdfPath);
struct ComponentInfo {
QString contents;
};
void convertComponentInfoAnnotations(const QString &pdfPath,
const QList<ComponentInfo> &annotations);
} }
+55
View File
@@ -20,10 +20,12 @@
#include "../diagram.h" #include "../diagram.h"
#include "../pdf_links.h" #include "../pdf_links.h"
#include "../qeticons.h" #include "../qeticons.h"
#include "../qetinformation.h"
#include "../qetproject.h" #include "../qetproject.h"
#include "../qetversion.h" #include "../qetversion.h"
#include "../qetgraphicsitem/crossrefitem.h" #include "../qetgraphicsitem/crossrefitem.h"
#include "../qetgraphicsitem/dynamicelementtextitem.h" #include "../qetgraphicsitem/dynamicelementtextitem.h"
#include "../qetgraphicsitem/element.h"
#include "../qetgraphicsitem/elementtextitemgroup.h" #include "../qetgraphicsitem/elementtextitemgroup.h"
#include "ui_projectprintwindow.h" #include "ui_projectprintwindow.h"
@@ -156,6 +158,7 @@ ProjectPrintWindow::ProjectPrintWindow(QETProject *project, QPrinter *printer, Q
ui->m_button_box->addButton(pdf_button, QDialogButtonBox::ActionRole); ui->m_button_box->addButton(pdf_button, QDialogButtonBox::ActionRole);
connect(pdf_button, &QPushButton::clicked, this, &ProjectPrintWindow::exportToPDF); connect(pdf_button, &QPushButton::clicked, this, &ProjectPrintWindow::exportToPDF);
} }
ui->m_component_info_cb->setVisible(m_printer->outputFormat() == QPrinter::PdfFormat);
auto exp = ExportProperties::defaultPrintProperties(); auto exp = ExportProperties::defaultPrintProperties();
ui->m_draw_border_cb->setChecked(exp.draw_border); ui->m_draw_border_cb->setChecked(exp.draw_border);
@@ -426,6 +429,50 @@ void ProjectPrintWindow::printDiagram(Diagram *diagram, bool fit_page, QPainter
PdfLinks::injectCrossRefLinks( PdfLinks::injectCrossRefLinks(
pdfEngine, diagram, geom, diagramPageMap, pdfEngine, diagram, geom, diagramPageMap,
printer->outputFileName()); printer->outputFileName());
////Collect component info for popup annotations////
if (ui->m_component_info_cb->isChecked()) {
for (auto *item : diagram->items()) {
auto *el = dynamic_cast<Element*>(item);
if (!el) continue;
// Skip reports and slaves
auto lt = el->linkType();
if (lt & Element::AllReport) continue;
if (lt == Element::Slave) continue;
auto info = el->elementInformations();
if (info.count() == 0) continue;
// Build info text
QStringList lines;
for (const QString &key : {"label", "manufacturer", "designation", "description"}) {
if (info.contains(key) && !info.value(key).toString().isEmpty())
lines << QETInformation::translatedInfoKey(key) + ": " + info.value(key).toString();
}
for (const QString &key : info.keys()) {
if (key == "formula") continue;
QString translated = QETInformation::translatedInfoKey(key);
if (lines.contains(translated + ": " + info.value(key).toString())) continue;
if (info.value(key).toString().isEmpty()) continue;
lines << translated + ": " + info.value(key).toString();
}
if (lines.isEmpty()) continue;
// Compute element rect in device pixels
QRectF elemScene = el->mapRectToScene(el->boundingRect());
QRectF devRect = fit.mapRect(elemScene);
PdfLinks::ComponentInfo ci;
ci.contents = lines.join("\n");
m_componentInfoList.append(ci);
// Create a link annotation as placeholder — post-processing
// will convert it to an invisible text annotation with the actual content
pdfEngine->drawHyperlink(devRect, QUrl("http://componentinfo.local/"));
}
}
////Component info end////
} }
} }
////PDF links end//// ////PDF links end////
@@ -799,6 +846,7 @@ void ProjectPrintWindow::on_m_draw_titleblock_cb_clicked() { m_preview->upd
void ProjectPrintWindow::on_m_keep_conductor_color_cb_clicked() { m_preview->updatePreview(); } void ProjectPrintWindow::on_m_keep_conductor_color_cb_clicked() { m_preview->updatePreview(); }
void ProjectPrintWindow::on_m_draw_terminal_cb_clicked() { m_preview->updatePreview(); } void ProjectPrintWindow::on_m_draw_terminal_cb_clicked() { m_preview->updatePreview(); }
void ProjectPrintWindow::on_m_draw_terminal_names_cb_clicked() { m_preview->updatePreview(); } void ProjectPrintWindow::on_m_draw_terminal_names_cb_clicked() { m_preview->updatePreview(); }
void ProjectPrintWindow::on_m_component_info_cb_clicked() { m_preview->updatePreview(); }
void ProjectPrintWindow::on_m_fit_in_page_cb_clicked() { m_preview->updatePreview(); } void ProjectPrintWindow::on_m_fit_in_page_cb_clicked() { m_preview->updatePreview(); }
void ProjectPrintWindow::on_m_use_full_page_cb_clicked() void ProjectPrintWindow::on_m_use_full_page_cb_clicked()
{ {
@@ -903,9 +951,16 @@ void ProjectPrintWindow::print()
// which prevents the black screen observed on Apple Silicon under // which prevents the black screen observed on Apple Silicon under
// macOS Sequoia (QPrintPreviewWidget + CALayer timing issue). // macOS Sequoia (QPrintPreviewWidget + CALayer timing issue).
QTimer::singleShot(0, this, [this, pdfFile]() { QTimer::singleShot(0, this, [this, pdfFile]() {
// Convert component-info link annotations into invisible text annotations
if (ui->m_component_info_cb->isChecked() && !m_componentInfoList.isEmpty()) {
PdfLinks::convertComponentInfoAnnotations(pdfFile, m_componentInfoList);
m_componentInfoList.clear();
}
// Convert URI link annotations into native internal GoTo/FitR // Convert URI link annotations into native internal GoTo/FitR
// actions so cross-references jump inside the document. // actions so cross-references jump inside the document.
PdfLinks::convertUriToGoTo(pdfFile); PdfLinks::convertUriToGoTo(pdfFile);
this->close(); this->close();
}); });
} else { } else {
+5 -2
View File
@@ -19,6 +19,7 @@
#define PROJECTPRINTWINDOW_H #define PROJECTPRINTWINDOW_H
#include "../exportproperties.h" #include "../exportproperties.h"
#include "../pdf_links.h"
#include <QMainWindow> #include <QMainWindow>
#include <QMap> #include <QMap>
@@ -55,8 +56,9 @@ class ProjectPrintWindow : public QMainWindow
void on_m_draw_titleblock_cb_clicked(); void on_m_draw_titleblock_cb_clicked();
void on_m_keep_conductor_color_cb_clicked(); void on_m_keep_conductor_color_cb_clicked();
void on_m_draw_terminal_cb_clicked(); void on_m_draw_terminal_cb_clicked();
void on_m_draw_terminal_names_cb_clicked(); void on_m_draw_terminal_names_cb_clicked();
void on_m_fit_in_page_cb_clicked(); void on_m_component_info_cb_clicked();
void on_m_fit_in_page_cb_clicked();
void on_m_use_full_page_cb_clicked(); void on_m_use_full_page_cb_clicked();
void on_m_zoom_out_action_triggered(); void on_m_zoom_out_action_triggered();
void on_m_zoom_in_action_triggered(); void on_m_zoom_in_action_triggered();
@@ -102,6 +104,7 @@ class ProjectPrintWindow : public QMainWindow
QPrintPreviewWidget *m_preview=nullptr; QPrintPreviewWidget *m_preview=nullptr;
QColor m_backup_diagram_background_color; QColor m_backup_diagram_background_color;
QHash<Diagram *, QCheckBox *> m_diagram_list_hash; QHash<Diagram *, QCheckBox *> m_diagram_list_hash;
QList<PdfLinks::ComponentInfo> m_componentInfoList;
}; };
#endif // PROJECTPRINTWINDOW_H #endif // PROJECTPRINTWINDOW_H
+21 -11
View File
@@ -183,17 +183,27 @@
</property> </property>
</widget> </widget>
</item> </item>
<item> <item>
<widget class="QCheckBox" name="m_draw_terminal_names_cb"> <widget class="QCheckBox" name="m_draw_terminal_names_cb">
<property name="text"> <property name="text">
<string>Dessiner les noms des bornes</string> <string>Dessiner les noms des bornes</string>
</property> </property>
<property name="checked"> <property name="checked">
<bool>false</bool> <bool>false</bool>
</property> </property>
</widget> </widget>
</item> </item>
</layout> <item>
<widget class="QCheckBox" name="m_component_info_cb">
<property name="text">
<string>Inscrire les informations des composants</string>
</property>
<property name="checked">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</widget> </widget>
</item> </item>
<item> <item>