Merge master: add cli_export, pdf_links; EDZ: 10-slot grid, group headers, ToS notice

- Resolve cmake/qet_compilation_vars.cmake conflict: keep both upstream's
  cli_export.cpp/h and pdf_links.cpp/h and the EDZ source additions.

- 10-position grid alignment: pin_y values are multiples of 10 so terminals
  snap cleanly to QET's default grid.  group_gap raised to 10 (one full slot).

- Named connector groups get a header label (group name) placed in the gap
  above the first pin, so the electrician sees block names (XDI, XPOW, …)
  without reading individual terminal designations.

- Device-tag dynamic_text now uses 9pt LABEL_FONT and y = min_y - 9 so it
  clears the element body and is legible at normal zoom.

- Add EPLAN Data Portal Terms of Use disclaimer to sources/import/edz/README.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Shane Ringrose
2026-06-21 21:00:49 +12:00
70 changed files with 6651 additions and 4941 deletions
@@ -469,7 +469,7 @@ bool ElementCollectionHandler::setNames(ElementsLocation &location,
root.appendChild(name_list.toXml(document));
QString filepath = location.fileSystemPath()
+ "/qet_directory";
% "/qet_directory";
if (!QET::writeXmlFile(document, filepath)) {
qDebug() << "ElementCollectionHandler::setNames : write qet-directory file failed";
return false;
@@ -538,6 +538,16 @@ QList<QETProject *> ElementsCollectionModel::project() const
*/
void ElementsCollectionModel::highlightUnusedElement()
{
//Reset only the items currently highlighted in red, so elements that
//are no longer unused lose the highlight. Scoping to the red
//Dense4Pattern avoids touching other backgrounds (e.g. the amber
//"show this dir" highlight) and avoids needless updates on big
//collections (issue #159).
for (ElementCollectionItem *eci : items())
if (eci->background().style() == Qt::Dense4Pattern &&
eci->background().color() == Qt::red)
eci->setBackground(QBrush());
QList <ElementsLocation> unused;
foreach (QETProject *project, m_project_list)
@@ -523,7 +523,9 @@ bool ElementsLocation::isCompanyCollection() const
*/
bool ElementsLocation::isCustomCollection() const
{
return fileSystemPath().startsWith(QETApp::customElementsDirN());
const QString dir = QETApp::customElementsDirN();
const QString path = fileSystemPath();
return path == dir || path.startsWith(dir + QLatin1Char('/'));
}
/**
@@ -274,7 +274,9 @@ bool FileElementCollectionItem::isCompanyCollection() const
*/
bool FileElementCollectionItem::isCustomCollection() const
{
return fileSystemPath().startsWith(QETApp::customElementsDirN());
const QString dir = QETApp::customElementsDirN();
const QString path = fileSystemPath();
return path == dir || path.startsWith(dir + QLatin1Char('/'));
}
/**
+825
View File
@@ -0,0 +1,825 @@
/*
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 "cli_export.h"
#include "bordertitleblock.h"
#include "conductornumexport.h"
#include "conductorproperties.h"
#include "dataBase/projectdatabase.h"
#include "diagram.h"
#include "diagramcontext.h"
#include "pdf_links.h"
#include "qetgraphicsitem/conductor.h"
#include "qetgraphicsitem/element.h"
#include "qetgraphicsitem/terminal.h"
#include "qetproject.h"
#include "titleblockproperties.h"
#include "wiringlistexport.h"
// Private Qt PDF engine for drawHyperlink() — see pdf_links / projectprintwindow.
#include <private/qpdf_p.h>
#include <QDir>
#include <QDirIterator>
#include <QDomDocument>
#include <QDate>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QMap>
#include <QPageLayout>
#include <QPair>
#include <QPainter>
#include <QPdfWriter>
#include <QSet>
#include <QSqlError>
#include <QSqlQuery>
#include <QSvgGenerator>
#include <QTextStream>
#include <QTransform>
namespace {
QTextStream out(stdout);
QTextStream err(stderr);
/// All CLI option flags, mapped to a short format name.
const QHash<QString, QString> &exportFlags()
{
static const QHash<QString, QString> flags {
{"--export-pdf", "pdf"},
{"--export-png", "png"},
{"--export-svg", "svg"},
{"--export-cables", "cables"},
{"--export-wires", "wires"},
{"--export-bom", "bom"},
{"--export-nets", "nets"},
{"--export-links", "links"},
{"--info", "info"},
{"--check-elements", "check"},
{"--resave", "resave"},
{"--set-titleblock", "settb"},
};
return flags;
}
/// Device tag of an element ("K1", "Q55"), falling back to its name.
QString elementLabel(Element *element)
{
const QString label = element->elementInformations()["label"].toString();
return label.isEmpty() ? element->name() : label;
}
/// Pixel rect of a diagram's border + title block (the printable page area).
QRect diagramRect(Diagram *diagram)
{
QRectF r = diagram->border_and_titleblock.borderAndTitleBlockRect();
r.adjust(0, 0, 1, 1); // include the 1px border line
return r.toAlignedRect();
}
/// A filesystem-safe per-diagram file stem: "01_Title".
QString diagramStem(Diagram *diagram, int index)
{
QString title = diagram->title();
title.replace(QRegularExpression("[^\\w \\-]"), "_");
title = title.simplified();
if (title.isEmpty())
title = "diagram";
return QStringLiteral("%1_%2")
.arg(index, 2, 10, QChar('0'))
.arg(title);
}
/// Render @p diagram into @p painter, fitting @p target to the page rect.
void renderDiagram(Diagram *diagram, QPainter &painter, const QRectF &target)
{
const QRect source = diagramRect(diagram);
// Export without the editor grid: drawBackground() only paints it when
// draw_grid_ is set (default true), so toggle it off around the render
// and restore it afterwards.
const bool was_drawing_grid = diagram->displayGrid();
diagram->setDisplayGrid(false);
diagram->render(&painter, target, source, Qt::KeepAspectRatio);
diagram->setDisplayGrid(was_drawing_grid);
}
int exportPdf(QETProject &project, const QString &output)
{
const QList<Diagram *> diagrams = project.diagrams();
if (diagrams.isEmpty()) {
err << "No diagrams to export.\n";
return 1;
}
// Page numbers (1-based) for cross-reference hyperlink targets: each
// diagram is exactly one page in the CLI export (no tiling).
QMap<Diagram *, int> pageMap;
for (int i = 0; i < diagrams.size(); ++i)
pageMap.insert(diagrams.at(i), i + 1);
QPdfWriter writer(output);
writer.setCreator("QElectroTech");
writer.setResolution(96);
QPainter painter;
bool first = true;
for (Diagram *diagram : diagrams) {
const QRect r = diagramRect(diagram);
// Match the page to the diagram (in points: 1px @ 96dpi = 0.75pt).
const QPageSize page(QSizeF(r.width() * 72.0 / 96.0,
r.height() * 72.0 / 96.0),
QPageSize::Point);
writer.setPageSize(page);
writer.setPageMargins(QMarginsF(0, 0, 0, 0));
if (first) {
if (!painter.begin(&writer)) {
err << "Cannot open '" << output << "' for writing.\n";
return 1;
}
first = false;
} else {
writer.newPage();
}
const QRectF target(0, 0,
writer.width(), writer.height());
renderDiagram(diagram, painter, target);
// Inject clickable cross-reference / folio-report hyperlinks for this
// page. The geometry is rebuilt from the QPdfWriter (not a QPrinter):
// render() anchors the diagram top-left with KeepAspectRatio, and the
// page is sized to the diagram so the scale is ~1.
if (auto *engine = dynamic_cast<QPdfEngine *>(painter.paintEngine())) {
const QRectF source(r);
const qreal s = qMin(target.width() / source.width(),
target.height() / source.height());
QTransform fit;
fit.translate(target.x(), target.y());
fit.scale(s, s);
fit.translate(-source.x(), -source.y());
// Device pixels -> PDF points, replicating the engine's page matrix
// (72/resolution scale + Y flip; zero margins -> no paint offset).
const qreal pt_scale = 72.0 / writer.resolution();
const qreal fullH_pt = writer.pageLayout().fullRectPoints().height();
const bool fullPageMode =
(writer.pageLayout().mode() == QPageLayout::FullPageMode);
const QRect paintPx =
writer.pageLayout().paintRectPixels(writer.resolution());
PdfLinks::PageGeometry geom;
geom.sceneToDevice = fit;
geom.target = target;
geom.pageBounds = QRectF(0, 0, target.width(), target.height());
geom.devToPdf = [=](const QPointF &d) -> QPointF {
qreal dx = d.x(), dy = d.y();
if (!fullPageMode) { dx += paintPx.left(); dy += paintPx.top(); }
return QPointF(pt_scale * dx, fullH_pt - pt_scale * dy);
};
geom.sourceRectOf = [](Diagram *dg) {
return QRectF(diagramRect(dg));
};
PdfLinks::injectCrossRefLinks(engine, diagram, geom, pageMap, output);
}
}
painter.end();
// Rewrite the URI link annotations into native internal GoTo actions, so
// the cross-references jump inside the document in any PDF viewer.
PdfLinks::convertUriToGoTo(output);
out << "Exported " << diagrams.size() << " page(s) -> " << output << "\n";
return 0;
}
int exportImages(QETProject &project, const QString &format,
const QString &out_dir)
{
const QList<Diagram *> diagrams = project.diagrams();
if (diagrams.isEmpty()) {
err << "No diagrams to export.\n";
return 1;
}
QDir().mkpath(out_dir);
int index = 0;
for (Diagram *diagram : diagrams) {
++index;
const QRect r = diagramRect(diagram);
const QString path = QDir(out_dir).filePath(
diagramStem(diagram, index) + "." + format);
if (format == "svg") {
QSvgGenerator gen;
gen.setFileName(path);
gen.setSize(r.size());
gen.setViewBox(QRect(0, 0, r.width(), r.height()));
gen.setTitle(diagram->title());
QPainter painter(&gen);
renderDiagram(diagram, painter, QRectF(QPointF(0, 0), r.size()));
painter.end();
} else { // png
QImage image(r.size(), QImage::Format_ARGB32);
image.fill(Qt::white);
QPainter painter(&image);
painter.setRenderHint(QPainter::Antialiasing, true);
renderDiagram(diagram, painter, QRectF(QPointF(0, 0), r.size()));
painter.end();
if (!image.save(path)) {
err << "Failed to write '" << path << "'.\n";
return 1;
}
}
out << " " << path << "\n";
}
out << "Exported " << diagrams.size() << " diagram(s) -> " << out_dir << "\n";
return 0;
}
int exportCsv(QETProject &project, const QString &format, const QString &output)
{
QString csv;
if (format == "cables") {
WiringListExport wle(&project, nullptr);
csv = wle.toCsvString();
} else { // wires
ConductorNumExport cne(&project, nullptr);
csv = cne.wiresNum();
}
if (csv.isEmpty()) {
err << "Nothing to export (empty list).\n";
return 1;
}
QFile file(output);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
err << "Cannot open '" << output << "' for writing.\n";
return 1;
}
QTextStream fout(&file);
fout << csv;
file.close();
out << "Exported " << format << " list -> " << output << "\n";
return 0;
}
/// Quote a field for CSV output (RFC-4180 style, ';' delimiter).
QString csvField(const QString &value)
{
if (value.contains(';') || value.contains('"')
|| value.contains('\n') || value.contains('\r')) {
QString v = value;
v.replace('"', "\"\"");
return '"' % v % '"';
}
return value;
}
/// Bill of materials: one row per element, key component-data fields.
/// Pulls from QET's own project database (the same source as the GUI BOM
/// export), so the output matches what the editor produces.
int exportBom(QETProject &project, const QString &output)
{
// The project database is built lazily; force a (re)build before querying.
project.dataBase()->updateDB();
static const QStringList columns {
"label", "designation", "manufacturer", "manufacturer_reference",
"quantity", "location", "function", "title", "folio"
};
QSqlQuery query = project.dataBase()->newQuery(
"SELECT " % columns.join(", ") %
" FROM element_nomenclature_view ORDER BY label");
if (!query.exec()) {
err << "BOM query failed: " << query.lastError().text() << "\n";
return 1;
}
QString csv = columns.join(";") % "\n";
int rows = 0;
while (query.next()) {
QStringList values;
for (int i = 0; i < columns.size(); ++i)
values << csvField(query.value(i).toString());
csv += values.join(";") % "\n";
++rows;
}
QFile file(output);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
err << "Cannot open '" << output << "' for writing.\n";
return 1;
}
QTextStream fout(&file);
fout << csv;
file.close();
out << "Exported " << rows << " component(s) -> " << output << "\n";
return 0;
}
/// Count terminals on @p element that no conductor connects to.
int freeTerminals(Element *element)
{
int free = 0;
const QList<Terminal *> terminals = element->terminals();
for (Terminal *t : terminals)
if (t->conductorsCount() == 0)
++free;
return free;
}
/// Structural ground-truth dump of a project, as JSON, to stdout (or a file).
/// Uses QET's own loaded model, so it reports what the editor actually sees:
/// per-page element / conductor counts and unconnected terminals.
int exportInfo(QETProject &project, const QString &output)
{
const QList<Diagram *> diagrams = project.diagrams();
int total_elements = 0, total_conductors = 0, total_free = 0;
QJsonArray pages;
int index = 0;
for (Diagram *diagram : diagrams) {
++index;
const QList<Element *> elements = diagram->elements();
const int conductors = diagram->conductors().size();
int page_free = 0;
for (Element *e : elements)
page_free += freeTerminals(e);
const QRect r = diagramRect(diagram);
QJsonObject page;
page["index"] = index;
page["title"] = diagram->title();
page["folio"] = QStringLiteral("%1 of %2")
.arg(index).arg(diagrams.size());
page["width_px"] = r.width();
page["height_px"] = r.height();
page["elements"] = elements.size();
page["conductors"] = conductors;
page["free_terminals"] = page_free;
pages.append(page);
total_elements += elements.size();
total_conductors += conductors;
total_free += page_free;
}
QJsonObject root;
root["project"] = project.title();
root["diagrams"] = diagrams.size();
root["elements"] = total_elements;
root["conductors"] = total_conductors;
root["free_terminals"] = total_free;
root["pages"] = pages;
const QByteArray json =
QJsonDocument(root).toJson(QJsonDocument::Indented);
if (output.isEmpty()) {
out << QString::fromUtf8(json);
} else {
QFile file(output);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
err << "Cannot open '" << output << "' for writing.\n";
return 1;
}
file.write(json);
file.close();
out << "Wrote project info -> " << output << "\n";
}
return 0;
}
/// Validate one .elmt file against QET's element schema.
/// @return 0 = OK, 1 = warning (loads but suspicious), 2 = failure.
int checkOneElement(const QString &path)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) {
out << "FAIL " << path << " (cannot open)\n";
return 2;
}
QDomDocument doc;
QString error;
int line = 0;
if (!doc.setContent(&file, &error, &line)) {
file.close();
out << "FAIL " << path << " (XML error line "
<< line << ": " << error << ")\n";
return 2;
}
file.close();
const QDomElement root = doc.documentElement();
if (root.tagName() != "definition" || root.attribute("type") != "element") {
out << "FAIL " << path << " (root is not <definition type=\"element\">)\n";
return 2;
}
bool w_ok = false, h_ok = false;
const double w = root.attribute("width").toDouble(&w_ok);
const double h = root.attribute("height").toDouble(&h_ok);
if (!w_ok || !h_ok || w == 0 || h == 0) {
out << "FAIL " << path << " (missing/zero bounding box "
<< root.attribute("width") << "x"
<< root.attribute("height") << ")\n";
return 2;
}
const int terminals = root.elementsByTagName("terminal").count();
// Negative dimensions are malformed but QET still loads them; surface as a
// warning rather than a failure so this agrees with QET's own loader.
if (w < 0 || h < 0) {
out << "WARN " << path << " (negative bounding box "
<< w << "x" << h << ", " << terminals << " terminals)\n";
return 1;
}
if (terminals == 0) {
out << "WARN " << path << " (loads, but 0 terminals)\n";
return 1;
}
out << "OK " << path << " (" << terminals << " terminals)\n";
return 0;
}
/// Validate a single .elmt file or every .elmt under a directory.
int checkElements(const QString &path)
{
QStringList files;
const QFileInfo info(path);
if (info.isDir()) {
QDirIterator it(path, {"*.elmt"}, QDir::Files,
QDirIterator::Subdirectories);
while (it.hasNext())
files << it.next();
files.sort();
} else if (info.isFile()) {
files << path;
} else {
err << "Not found: " << path << "\n";
return 2;
}
if (files.isEmpty()) {
err << "No .elmt files found under: " << path << "\n";
return 2;
}
int warnings = 0, failures = 0;
for (const QString &f : files) {
const int r = checkOneElement(f);
if (r == 1) ++warnings;
else if (r == 2) ++failures;
}
out << files.size() << " file(s), " << warnings
<< " warning(s), " << failures << " failure(s)\n";
return failures > 0 ? 1 : 0;
}
/// Map every element in the project to its 1-based folio (page) position.
QHash<Element *, int> folioIndex(QETProject &project)
{
QHash<Element *, int> folio;
int index = 0;
const QList<Diagram *> diagrams = project.diagrams();
for (Diagram *diagram : diagrams) {
++index;
const QList<Element *> elements = diagram->elements();
for (Element *e : elements)
folio.insert(e, index);
}
return folio;
}
/// Electrical nets: groups of terminals joined into one potential.
/// Walks QET's own potential graph, so each net is a connected component
/// of terminals across all folios. The ground truth for connectivity.
int exportNets(QETProject &project, const QString &output)
{
const QHash<Element *, int> folio = folioIndex(project);
QList<Conductor *> all_conductors;
const QList<Diagram *> diagrams = project.diagrams();
for (Diagram *diagram : diagrams)
all_conductors << diagram->conductors();
QSet<Conductor *> visited;
QJsonArray nets;
int net_no = 0;
for (Conductor *c : all_conductors) {
if (visited.contains(c))
continue;
// The whole potential this conductor belongs to. relatedPotential-
// Conductors() also fills t_list with every terminal in the net
// (following folio reports and terminal blocks too).
QList<Terminal *> t_list;
QSet<Conductor *> group = c->relatedPotentialConductors(true, &t_list);
group.insert(c);
for (Conductor *g : group)
visited.insert(g);
if (c->terminal1) t_list << c->terminal1;
if (c->terminal2) t_list << c->terminal2;
// Wire number: smallest non-empty conductor text (deterministic).
QStringList wire_nos;
for (Conductor *g : group)
if (!g->properties().text.isEmpty())
wire_nos << g->properties().text;
wire_nos.sort();
++net_no;
QJsonArray terminals;
QSet<Terminal *> seen;
for (Terminal *t : t_list) {
if (!t || seen.contains(t))
continue;
seen.insert(t);
Element *pe = t->parentElement();
QJsonObject to;
to["element"] = pe ? elementLabel(pe) : QString();
to["terminal"] = t->name();
to["folio"] = pe ? folio.value(pe, 0) : 0;
terminals.append(to);
}
QJsonObject net;
net["net"] = net_no;
net["wire_no"] = wire_nos.value(0);
net["terminals"] = terminals;
nets.append(net);
}
QJsonObject root;
root["project"] = project.title();
root["nets"] = nets.size();
root["list"] = nets;
QFile file(output);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
err << "Cannot open '" << output << "' for writing.\n";
return 1;
}
file.write(QJsonDocument(root).toJson(QJsonDocument::Indented));
file.close();
out << "Exported " << nets.size() << " net(s) -> " << output << "\n";
return 0;
}
/// Cross-references: each linkable element (coil / contact / report) and the
/// elements it links to, flagging masters/slaves with no link as unresolved.
int exportLinks(QETProject &project, const QString &output)
{
const QHash<Element *, int> folio = folioIndex(project);
QString csv("element;link_type;linked_to;folio;status\n");
int linkable = 0, unresolved = 0;
const QList<Diagram *> diagrams = project.diagrams();
for (Diagram *diagram : diagrams) {
const QList<Element *> elements = diagram->elements();
for (Element *e : elements) {
if (e->linkType() == Element::Simple)
continue;
++linkable;
const QList<Element *> linked = e->linkedElements();
QStringList names;
for (Element *le : linked)
names << elementLabel(le) % "(f"
% QString::number(folio.value(le, 0)) % ")";
QString status = "linked";
if ((e->linkType() == Element::Master
|| e->linkType() == Element::Slave)
&& linked.isEmpty()) {
status = "UNRESOLVED";
++unresolved;
}
csv += csvField(elementLabel(e)) % ";"
% e->linkTypeToString() % ";"
% csvField(names.join(", ")) % ";"
% QString::number(folio.value(e, 0)) % ";"
% status % "\n";
}
}
QFile file(output);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
err << "Cannot open '" << output << "' for writing.\n";
return 1;
}
QTextStream fout(&file);
fout << csv;
file.close();
out << "Exported " << linkable << " linkable element(s), "
<< unresolved << " unresolved -> " << output << "\n";
return 0;
}
/// Round-trip: load the project and write its XML back out, so an external
/// diff can reveal markup QET silently normalises (tolerated-but-invalid XML).
int resaveProject(QETProject &project, const QString &output)
{
const QDomDocument doc = project.toXml();
QFile file(output);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
err << "Cannot open '" << output << "' for writing.\n";
return 1;
}
QTextStream fout(&file);
fout << doc.toString(4);
file.close();
out << "Re-saved project -> " << output << "\n";
return 0;
}
/// Stamp title-block fields onto every folio (and the project default), then
/// save. Each assignment is "key=value". Standard keys map to the documented
/// title-block fields; "date=today" uses the current date; any other key is
/// stored as a custom title-block field. Aimed at CI/revision workflows
/// (e.g. set revision + date before exporting a new revision).
int setTitleBlock(QETProject &project, const QString &output,
const QStringList &assignments)
{
if (assignments.isEmpty()) {
err << "No field assignments given (expected key=value).\n";
return 2;
}
// Parse "key=value" assignments up front so a bad one fails before writing.
QList<QPair<QString, QString>> fields;
for (const QString &a : assignments) {
const int eq = a.indexOf('=');
if (eq <= 0) {
err << "Bad assignment '" << a << "' (expected key=value).\n";
return 2;
}
const QString key = a.left(eq);
const QString val = a.mid(eq + 1);
if (key.compare("date", Qt::CaseInsensitive) == 0
&& val.compare("today", Qt::CaseInsensitive) != 0
&& !QDate::fromString(val, Qt::ISODate).isValid()) {
err << "Bad date '" << val << "' (expected YYYY-MM-DD or 'today').\n";
return 2;
}
fields << qMakePair(key, val);
}
auto apply = [&](TitleBlockProperties &p) {
for (const auto &f : fields) {
const QString k = f.first.toLower();
const QString &v = f.second;
if (k == "title") p.title = v;
else if (k == "author") p.author = v;
else if (k == "filename") p.filename = v;
else if (k == "plant") p.plant = v;
else if (k == "location") p.locmach = v;
else if (k == "revision") p.indexrev = v;
else if (k == "version") p.version = v;
else if (k == "date") {
p.date = (v.compare("today", Qt::CaseInsensitive) == 0)
? QDate::currentDate()
: QDate::fromString(v, Qt::ISODate);
// An explicit date is only honoured when the folio is in
// "use the date value" mode (not "now"/"null").
p.useDate = TitleBlockProperties::UseDateValue;
}
else // unknown key -> custom title-block field
p.context.addValue(f.first, v);
}
};
// Project default (the template applied to new folios).
TitleBlockProperties def = project.defaultTitleBlockProperties();
apply(def);
project.setDefaultTitleBlockProperties(def);
// Every existing folio's own title block.
int folios = 0;
const QList<Diagram *> diagrams = project.diagrams();
for (Diagram *diagram : diagrams) {
TitleBlockProperties p =
diagram->border_and_titleblock.exportTitleBlock();
apply(p);
diagram->border_and_titleblock.importTitleBlock(p);
++folios;
}
const QDomDocument doc = project.toXml();
QFile file(output);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
err << "Cannot open '" << output << "' for writing.\n";
return 1;
}
QTextStream fout(&file);
fout << doc.toString(4);
file.close();
out << "Stamped " << fields.size() << " field(s) on "
<< folios << " folio(s) -> " << output << "\n";
return 0;
}
} // anonymous namespace
namespace CLIExport {
bool isExportRequest(const QStringList &args)
{
for (const QString &a : args)
if (exportFlags().contains(a))
return true;
return false;
}
int run(const QStringList &args)
{
QString flag;
QStringList rest;
for (int i = 0; i < args.size(); ++i) {
if (exportFlags().contains(args.at(i))) {
flag = args.at(i);
for (int j = i + 1; j < args.size(); ++j)
rest << args.at(j);
break;
}
}
const QString format = exportFlags().value(flag);
// --check-elements operates on an element file/directory, not a project.
if (format == "check") {
if (rest.isEmpty()) {
err << "Usage: qelectrotech --check-elements "
"<element.elmt | directory>\n";
return 2;
}
return checkElements(rest.at(0));
}
const QString input = rest.value(0);
if (input.isEmpty()) {
err << "Usage: qelectrotech " << flag << " <project.qet> <output>\n";
return 2;
}
if (!QFileInfo::exists(input)) {
err << "Project not found: " << input << "\n";
return 2;
}
QETProject project(input);
if (project.state() != QETProject::Ok) {
err << "Failed to open project: " << input
<< " (state " << project.state() << ")\n";
return 1;
}
// --info writes JSON to stdout, or to an optional output file.
if (format == "info")
return exportInfo(project, rest.value(1));
const QString output = rest.value(1);
if (output.isEmpty()) {
err << "Usage: qelectrotech " << flag
<< " <project.qet> <output>\n";
return 2;
}
if (format == "pdf")
return exportPdf(project, output);
if (format == "cables" || format == "wires")
return exportCsv(project, format, output);
if (format == "bom")
return exportBom(project, output);
if (format == "nets")
return exportNets(project, output);
if (format == "links")
return exportLinks(project, output);
if (format == "resave")
return resaveProject(project, output);
if (format == "settb")
return setTitleBlock(project, output, rest.mid(2));
return exportImages(project, format, output);
}
} // namespace CLIExport
+79
View File
@@ -0,0 +1,79 @@
/*
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/>.
*/
#ifndef CLI_EXPORT_H
#define CLI_EXPORT_H
#include <QStringList>
/**
@brief Headless command-line export.
Implements the long-requested batch/headless export
(qelectrotech.org bugtracker #171, GitHub #309): render a project's
diagrams to files without opening the GUI.
Detected and handled in main() before the GUI is created.
*/
namespace CLIExport {
/**
@brief True if @p args request a CLI export
(i.e. contain one of the export options).
*/
bool isExportRequest(const QStringList &args);
/**
@brief Run the CLI export described by @p args.
@return process exit code (0 on success).
Usage:
qelectrotech --export-pdf <project.qet> <output.pdf>
qelectrotech --export-png <project.qet> <output_dir>
qelectrotech --export-svg <project.qet> <output_dir>
qelectrotech --export-cables <project.qet> <output.csv>
qelectrotech --export-wires <project.qet> <output.csv>
qelectrotech --export-bom <project.qet> <output.csv>
qelectrotech --export-nets <project.qet> <output.json>
qelectrotech --export-links <project.qet> <output.csv>
qelectrotech --info <project.qet> [output.json]
qelectrotech --check-elements <element.elmt | directory>
qelectrotech --resave <project.qet> <output.qet>
qelectrotech --set-titleblock <project.qet> <output.qet> key=value...
PDF: one multi-page document (one diagram per page).
PNG/SVG: one file per diagram, named <output_dir>/<NN>_<title>.<ext>.
cables: wiring list (one row per conductor) as CSV.
wires: list of distinct wire numbers as CSV.
bom: bill of materials (one row per element) as CSV.
nets: electrical nets (connected-terminal groups) as JSON.
links: element cross-references (coil/contact) as CSV, with
unresolved links flagged.
info: structural project summary as JSON (stdout, or a file) —
per-page element / conductor counts and unconnected terminals.
check-elements: validate .elmt file(s) against the element schema.
resave: load and rewrite the project XML (round-trip integrity).
set-titleblock: stamp title-block fields onto every folio, then save.
Keys: title, author, date (or date=today), plant, location,
revision, version, filename; any other key becomes a custom
field. E.g. --set-titleblock in.qet out.qet revision=B date=today
*/
int run(const QStringList &args);
}
#endif // CLI_EXPORT_H
+6
View File
@@ -313,6 +313,12 @@ void PartText::setPlainText(const QString &text) {
void PartText::setFont(const QFont &font) {
if (font != this -> font()) {
QGraphicsTextItem::setFont(font);
// Re-anchor: the item's position transform is -margin(), and margin()
// depends on the font ascent. Without re-running this on a font change,
// the transform keeps the previous font's ascent — so the text renders
// at a different spot after save/reopen (the position recomputes from
// the saved font on load). See #158.
adjustItemPosition();
emit fontChanged(font);
}
}
+38 -36
View File
@@ -736,11 +736,13 @@ bool QETElementEditor::checkElement()
QList<QETWarning> warnings;
QList<QETWarning> errors;
// Warning #1: Element haven't got terminal
// Warning #1: Element does not have (enough) terminals
// (except for report and conductor definition, because they must have one terminal and this checking is done below)
// (another exception: "thumbnails" aka "front-views" may/should not have terminals)
if (!m_elmt_scene -> containsTerminals() &&
!(m_elmt_scene->elementData().m_type & ElementData::AllReport) &&
m_elmt_scene->elementData().m_type != ElementData::ConductorDefinition) {
m_elmt_scene->elementData().m_type != ElementData::ConductorDefinition &&
m_elmt_scene->elementData().m_type != ElementData::Thumbnail) {
warnings << qMakePair(
tr("Absence de borne", "warning title"),
tr(
@@ -749,50 +751,50 @@ bool QETElementEditor::checkElement()
"warning description"
)
);
}
}
// Check folio report element
if (m_elmt_scene->elementData().m_type & ElementData::AllReport)
{
int terminal =0;
// Check folio report element
if (m_elmt_scene->elementData().m_type & ElementData::AllReport)
{
int terminal =0;
for(auto qgi : m_elmt_scene -> items()) {
if (qgraphicsitem_cast<PartTerminal *>(qgi)) {
terminal ++;
}
}
//Error folio report must have only one terminal
if (terminal != 1) {
errors << qMakePair (tr("Absence de borne"),
tr("<br><b>Erreur</b> :"
"<br>Les reports de folio doivent posséder une seul borne."
"<br><b>Solution</b> :"
"<br>Verifier que l'élément ne possède qu'une seul borne"));
for(auto qgi : m_elmt_scene -> items()) {
if (qgraphicsitem_cast<PartTerminal *>(qgi)) {
terminal ++;
}
}
// Check conductor definition element
if (m_elmt_scene->elementData().m_type == ElementData::ConductorDefinition)
{
int terminal =0;
//Error folio report must have only one terminal
if (terminal != 1) {
errors << qMakePair (tr("Absence de borne"),
tr("<br><b>Erreur</b> :"
"<br>Les reports de folio doivent posséder une seul borne."
"<br><b>Solution</b> :"
"<br>Verifier que l'élément ne possède qu'une seul borne"));
}
}
for(auto qgi : m_elmt_scene -> items()) {
if (qgraphicsitem_cast<PartTerminal *>(qgi)) {
terminal ++;
}
}
// Check conductor definition element
if (m_elmt_scene->elementData().m_type == ElementData::ConductorDefinition)
{
int terminal =0;
// Error: Conductor definition must have exactly one terminal
if (terminal != 1) {
errors << qMakePair (tr("Nombre de bornes incorrect"),
tr("<br><b>Erreur</b> :"
"<br>Les définitions de conducteur ne peuvent posséder qu'une seule borne."
"<br><b>Solution</b> :"
"<br>Vérifier que l'élément ne possède qu'une seule borne"));
for(auto qgi : m_elmt_scene -> items()) {
if (qgraphicsitem_cast<PartTerminal *>(qgi)) {
terminal ++;
}
}
// Error: Conductor definition must have exactly one terminal
if (terminal != 1) {
errors << qMakePair (tr("Nombre de bornes incorrect"),
tr("<br><b>Erreur</b> :"
"<br>Les définitions de conducteur ne peuvent posséder qu'une seule borne."
"<br><b>Solution</b> :"
"<br>Vérifier que l'élément ne possède qu'une seule borne"));
}
}
if (!errors.count() && !warnings.count()) {
return(true);
}
+11 -1
View File
@@ -124,7 +124,17 @@ void ElementDialog::setUpWidget()
} else if (m_mode == SaveTemplate) {
m_text_field->setPlaceholderText(tr("Nom du nouveau template"));
} else {
m_text_field->setPlaceholderText(tr("Nom du nouvel élément"));
// This is the element's file name, not its display name: the field
// only accepts file-name characters (QFileNameEdit). The visible
// element name is edited separately in the element properties.
m_text_field->setPlaceholderText(
tr("Nom de fichier de l'élément",
"placeholder: the element's file name, not its display name"));
m_text_field->setToolTip(
tr("Nom de fichier de l'élément : chiffres, minuscules, « - », "
"« _ » et « . » uniquement.\nLe nom affiché de l'élément se "
"modifie séparément dans les propriétés de l'élément.",
"tooltip for the element file-name field"));
}
layout->addWidget(m_text_field);
+18
View File
@@ -44,6 +44,24 @@ used:
The non-UI classes are deliberately decoupled from the widget so they can be
tested headless.
## Terms of Use / Legal notice
The `.edz` files themselves are downloaded from the **EPLAN Data Portal**
(data.eplan.com), which is operated by EPLAN Software & Service GmbH & Co. KG.
The Data Portal Terms of Use (§ 5.4 at time of writing) restrict use of
downloaded data to EPLAN products.
**QElectroTech does not endorse or encourage violation of those terms.**
Whether use of an `.edz` outside EPLAN products is permitted under your
specific licence depends on the agreement you have with EPLAN and the
individual manufacturer. You are solely responsible for ensuring that your use
of `.edz` data complies with the applicable Terms of Use and any applicable
law. If in doubt, contact EPLAN or the part manufacturer directly.
QET's `.edz` reader parses only the portable, factual part data
(pin designations, order numbers, manufacturer names). It does not reproduce
any EPLAN-proprietary geometry or symbol data.
## Bundled LZMA SDK
`lzma/` contains the decode-only subset of Igor Pavlov's **LZMA SDK** (public
+25 -4
View File
@@ -34,6 +34,8 @@ const QString FONT =
QStringLiteral("Liberation Sans,5,-1,5,50,0,0,0,0,0,Regular");
const QString TITLE_FONT =
QStringLiteral("Liberation Sans,5,-1,5,75,0,0,0,0,0,Bold");
const QString LABEL_FONT =
QStringLiteral("Liberation Sans,9,-1,5,50,0,0,0,0,0,Regular");
QString uuidStr()
{
@@ -71,19 +73,25 @@ QDomDocument EdzElementBuilder::build(const EdzPart &part)
}
const int n = pins.size();
const int pitch = 10;
const int group_gap = 5; // extra px inserted between designation groups
const int group_gap = 10; // one full slot between named connector groups
// Pins arrive sorted by functional group then by designation within each
// group (see EdzPart::parse). A group break occurs when the
// functiondefinition block changes; fall back to designation comparison for
// parts that carry no functiondefinition information.
// No gap is inserted for power/busbar pins that carry no group label —
// they flow consecutively so a 3-phase AC source stays connected.
auto groupKey = [](const EdzPin &p) {
return p.group.isEmpty() ? p.designation : p.group;
};
QVector<bool> is_group_break(n, false);
for (int i = 1; i < n; ++i)
is_group_break[i] = (groupKey(pins.at(i)) != groupKey(pins.at(i - 1)));
is_group_break[i] = (groupKey(pins.at(i)) != groupKey(pins.at(i - 1)))
&& (!pins.at(i).group.isEmpty());
// Place every terminal on a multiple-of-10 y coordinate so they align
// cleanly with QET's default grid. The gap slot is also 10 px, giving
// one empty grid row between connector groups.
QVector<int> pin_y(n);
int cur_y = 0;
for (int i = 0; i < n; ++i) {
@@ -218,10 +226,23 @@ QDomDocument EdzElementBuilder::build(const EdzPart &part)
desc.appendChild(makeText(doc, 6, pin_y[i] + 2, label, FONT));
}
// Connector group header labels — one per named group, in the gap above
// the group's first pin so the electrician can see the terminal block name
// (e.g. "XDI", "XPOW") without having to read individual terminal names.
for (int i = 0; i < n; ++i) {
if (pins.at(i).group.isEmpty()) continue;
if (i > 0 && groupKey(pins.at(i)) == groupKey(pins.at(i - 1))) continue;
// Place the label centred in the gap slot above the group's first pin.
const int label_y = (i == 0) ? (pin_y[0] - group_gap / 2)
: (pin_y[i] - group_gap / 2);
desc.appendChild(makeText(doc, body_left + 2, label_y,
pins.at(i).group, FONT));
}
// Device-tag label (per-instance, above the body).
QDomElement dyn = doc.createElement(QStringLiteral("dynamic_text"));
dyn.setAttribute(QStringLiteral("x"), min_x + pad);
dyn.setAttribute(QStringLiteral("y"), min_y - 2);
dyn.setAttribute(QStringLiteral("y"), min_y - 9);
dyn.setAttribute(QStringLiteral("z"), QStringLiteral("5"));
dyn.setAttribute(QStringLiteral("text_width"), QStringLiteral("-1"));
dyn.setAttribute(QStringLiteral("Halignment"), QStringLiteral("AlignLeft"));
@@ -231,7 +252,7 @@ QDomDocument EdzElementBuilder::build(const EdzPart &part)
dyn.setAttribute(QStringLiteral("keep_visual_rotation"), QStringLiteral("false"));
dyn.setAttribute(QStringLiteral("text_from"), QStringLiteral("ElementInfo"));
dyn.setAttribute(QStringLiteral("uuid"), uuidStr());
dyn.setAttribute(QStringLiteral("font"), FONT);
dyn.setAttribute(QStringLiteral("font"), LABEL_FONT);
dyn.appendChild(doc.createElement(QStringLiteral("text")));
QDomElement info_name = doc.createElement(QStringLiteral("info_name"));
info_name.appendChild(doc.createTextNode(QStringLiteral("label")));
+5 -5
View File
@@ -75,11 +75,11 @@ class MachineInfo
{
struct Screen
{
int32_t count;
int32_t width[10];
int32_t height[10];
int32_t Max_width;
int32_t Max_height;
int32_t count = 0;
int32_t width[10] = {};
int32_t height[10]= {};
int32_t Max_width = 0;
int32_t Max_height= 0;
}screen;
struct Built
{
+26
View File
@@ -15,13 +15,17 @@
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#include "cli_export.h"
#include "machine_info.h"
#include "qet.h"
#include "qetapp.h"
#include "qetproject.h"
#include "singleapplication.h"
#include "utils/macosxopenevent.h"
#include "utils/qetsettings.h"
#include <QApplication>
#include <QStyleFactory>
#include <QtConcurrentRun>
@@ -194,6 +198,23 @@ QGuiApplication::setHighDpiScaleFactorRoundingPolicy(QetSettings::hdpiScaleFacto
#endif
// Headless command-line export: render a project to PDF/PNG/SVG without
// opening the GUI, then exit. Must be handled before SingleApplication
// (which would forward the args to an already-running instance).
{
QStringList raw_args;
for (int i = 0; i < argc; ++i)
raw_args << QString::fromLocal8Bit(argv[i]);
if (CLIExport::isExportRequest(raw_args)) {
QApplication export_app(argc, argv);
// No crash-recovery backups in one-shot CLI mode: the backup write
// runs on a background thread referencing the project and races the
// process exit (intermittent segfault in QET::writeToFile).
QETProject::setBackupEnabled(false);
return CLIExport::run(export_app.arguments());
}
}
SingleApplication app(argc, argv, true);
#ifdef Q_OS_MACOS
//Handle the opening of QET when user double click on a .qet .elmt .tbt file
@@ -220,6 +241,11 @@ QGuiApplication::setHighDpiScaleFactorRoundingPolicy(QetSettings::hdpiScaleFacto
QObject::connect(&app, &SingleApplication::receivedMessage,
&qetapp, &QETApp::receiveMessage);
// Pre-initialise on the main (GUI) thread: the constructor calls
// qApp->screens() which is not thread-safe in Qt5 — calling instance()
// here guarantees the singleton is fully built before the worker runs.
MachineInfo::instance();
QtConcurrent::run([=]()
{
// for debugging
+382
View File
@@ -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
+79
View File
@@ -0,0 +1,79 @@
/*
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/>.
*/
#ifndef PDF_LINKS_H
#define PDF_LINKS_H
#include <QMap>
#include <QPointF>
#include <QRectF>
#include <QString>
#include <QTransform>
#include <functional>
class QPdfEngine;
class Diagram;
/**
Shared helper that turns a project's cross-references and folio reports
into clickable internal hyperlinks in a Qt-generated PDF. Used by both the
GUI print path (ProjectPrintWindow) and the headless CLI export, each of
which builds its own page geometry and passes it in — this code never
computes the scene-to-page mapping itself.
*/
namespace PdfLinks {
/**
Geometry mapping for one rendered PDF page. Each caller builds this
from its OWN page setup (printer page layout vs QPdfWriter), since the
device-pixel and point conversions differ between them.
*/
struct PageGeometry {
/// scene coordinates -> device pixels (the same "fit" render() applied)
QTransform sceneToDevice;
/// device paint rectangle, in pixels (the page area)
QRectF target;
/// links whose rectangle falls outside this are dropped
QRectF pageBounds;
/// device pixels -> PDF points (replicates the engine's page matrix)
std::function<QPointF(const QPointF &)> devToPdf;
/// a diagram -> its source rectangle in scene pixels (for /FitR framing)
std::function<QRectF(Diagram *)> sourceRectOf;
};
/**
Inject clickable cross-reference / folio-report hyperlinks for @p diagram
into the current page of @p engine. Each link is emitted as a URI
annotation encoding the target page and a /FitR rectangle;
convertUriToGoTo() then rewrites those into native internal GoTo actions.
*/
void injectCrossRefLinks(QPdfEngine *engine, Diagram *diagram,
const PageGeometry &geom,
const QMap<Diagram *, int> &pageMap,
const QString &outputFileName);
/**
Post-process a Qt-generated PDF file: rewrite every "/S /URI" link
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
such annotations.
*/
void convertUriToGoTo(const QString &pdfPath);
}
#endif // PDF_LINKS_H
+15 -352
View File
@@ -18,6 +18,7 @@
#include "projectprintwindow.h"
#include "../diagram.h"
#include "../pdf_links.h"
#include "../qeticons.h"
#include "../qetproject.h"
#include "../qetversion.h"
@@ -200,241 +201,6 @@ ProjectPrintWindow::~ProjectPrintWindow()
* @brief ProjectPrintWindow::requestPaint
* @param slot called when m_preview emit paintRequested
*/
/**
* @brief ProjectPrintWindow::pdfConvertUriToGoTo
* Post-processes a Qt-generated PDF to replace URI link annotations
* (file:///path/to/file.pdf#page=N) with native PDF GoTo actions
* ([pageObj 0 R /Fit]). This makes cross-reference links work in all
* PDF viewers regardless of where the file is stored.
*
* The function:
* 1. Reads the PDF as raw bytes.
* 2. Collects page object numbers in document order by scanning for
* objects that contain "/Type /Page" (but not "/Type /Pages").
* 3. Replaces every annotation action block
* /S /URI\n/URI (file://...#page=N)
* with
* /S /GoTo\n/D [<pageObj> 0 R /Fit]
* 4. Rebuilds the cross-reference table (offsets change because the
* replacement strings have different lengths).
* 5. Writes the result back to the same file.
*
* The function is intentionally conservative: if any step fails (file
* not found, malformed PDF, no URI annotations) it returns silently
* without corrupting the file.
*/
static void pdfConvertUriToGoTo(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();
}
void ProjectPrintWindow::requestPaint()
{
#if QT_VERSION >= QT_VERSION_CHECK(5, 6, 0)
@@ -489,6 +255,9 @@ void ProjectPrintWindow::requestPaint()
const bool pdfExport =
(m_printer->outputFormat() == QPrinter::PdfFormat)
&& (dynamic_cast<QPdfEngine*>(painter.paintEngine()) != nullptr);
// plc-user: added because of "Warning: unused variable 'pdfExport'":
// should be fixed by original author by evaluating function-result!
(void)pdfExport;
for (auto diagram : selectedDiagram())
{
@@ -642,123 +411,17 @@ void ProjectPrintWindow::printDiagram(Diagram *diagram, bool fit_page, QPainter
return QPointF(pt_scale * dx, fullH_pt - pt_scale * dy);
};
// 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 = QRectF(diagramRect(dg, exportProperties()));
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 = devToPdf(devT.topLeft());
const QPointF b = 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())));
PdfLinks::PageGeometry geom;
geom.sceneToDevice = fit;
geom.target = target;
geom.pageBounds = pageBounds;
geom.devToPdf = devToPdf;
geom.sourceRectOf = [this](Diagram *dg) {
return QRectF(diagramRect(dg, exportProperties()));
};
auto injectLink = [&](const QRectF &sceneRect, Element *targetElmt) {
if (!targetElmt || !targetElmt->diagram()) return;
const int targetPage =
diagramPageMap.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(printer->outputFileName());
url.setFragment(frag);
pdfEngine->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() est en coords LOCALES du CrossRefItem -> 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;
}
}
PdfLinks::injectCrossRefLinks(
pdfEngine, diagram, geom, diagramPageMap,
printer->outputFileName());
}
}
////PDF links end////
@@ -1235,7 +898,7 @@ void ProjectPrintWindow::print()
QTimer::singleShot(0, this, [this, pdfFile]() {
// Convert URI link annotations into native internal GoTo/FitR
// actions so cross-references jump inside the document.
pdfConvertUriToGoTo(pdfFile);
PdfLinks::convertUriToGoTo(pdfFile);
this->close();
});
} else {
+48 -24
View File
@@ -226,20 +226,20 @@ void QETApp::setLanguage(const QString &desired_language) {
// load translations for the QET application
// charge les traductions pour l'application QET
if (!qetTranslator.load("qet_" + desired_language, languages_path)) {
/* in case of failure,
* we fall back on the native channels for French
* en cas d'echec,
* on retombe sur les chaines natives pour le francais
*/
if (desired_language != "fr") {
// use of the English version by default
// utilisation de la version anglaise par defaut
if(!qetTranslator.load("qet_en", languages_path))
qWarning() << "failed to load"
<< "qet_en" << languages_path << "(" << __FILE__
<< __LINE__ << __FUNCTION__ << ")";
}
// desired_language may be a full locale such as "pt_BR": try that exact
// translation, then the base language ("pt"), then fall back to English.
// French is the application's source language and needs no translation.
const QString base_language = desired_language.section('_', 0, 0);
bool loaded = qetTranslator.load("qet_" + desired_language, languages_path);
if (!loaded && base_language != desired_language)
loaded = qetTranslator.load("qet_" + base_language, languages_path);
if (!loaded && base_language != "fr") {
// use of the English version by default
// utilisation de la version anglaise par defaut
if(!qetTranslator.load("qet_en", languages_path))
qWarning() << "failed to load"
<< "qet_en" << languages_path << "(" << __FILE__
<< __LINE__ << __FUNCTION__ << ")";
}
qApp->installTranslator(&qetTranslator);
@@ -263,7 +263,11 @@ QString QETApp::langFromSetting()
QSettings settings;
system_language = settings.value("lang", "system").toString();
if(system_language == "system") {
system_language = QLocale::system().name().left(2);
// Keep the full locale (e.g. "pt_BR"), not just the base language
// ("pt"): QET ships regional translations (pt_BR, nl_BE, nl_NL) and
// truncating here loaded the wrong one. setLanguage() falls back to
// the base language when no regional translation exists.
system_language = QLocale::system().name();
}
lang_is_set = true;
}
@@ -883,10 +887,13 @@ QString QETApp::configDir()
#ifdef QET_ALLOW_OVERRIDE_CD_OPTION
if (config_dir != QString()) return(config_dir);
#endif
QString configdir = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation);
while (configdir.endsWith('/')) {
configdir.remove(configdir.length()-1, 1);
}
// C++11 static-local init runs exactly once across all threads — safe to
// call from QtConcurrent background threads (QStandardPaths is not).
static const QString configdir = []() {
QString d = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation);
while (d.endsWith('/')) d.chop(1);
return d;
}();
return configdir;
}
@@ -907,10 +914,13 @@ QString QETApp::dataDir()
#ifdef QET_ALLOW_OVERRIDE_DD_OPTION
if (data_dir != QString()) return(data_dir);
#endif
QString datadir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
while (datadir.endsWith('/')) {
datadir.remove(datadir.length()-1, 1);
}
// C++11 static-local init runs exactly once across all threads — safe to
// call from QtConcurrent background threads (QStandardPaths is not).
static const QString datadir = []() {
QString d = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
while (d.endsWith('/')) d.chop(1);
return d;
}();
return datadir;
}
@@ -1228,7 +1238,21 @@ QString QETApp::languagesPath()
* en l'absence d'option de compilation, on utilise le dossier lang,
* situe a cote du binaire executable
*/
return(QCoreApplication::applicationDirPath() + "/lang/");
{
const QString bin_dir = QCoreApplication::applicationDirPath();
const QString next_to_bin = bin_dir + "/lang/";
// Some packagings (notably the Windows installer) put the binary in a
// "bin" subfolder while "lang" sits beside it (../lang). Fall back to
// that layout when the folder next to the binary is absent, so the
// translations are found without a --lang-dir argument. See issue #86.
if (!QDir(next_to_bin).exists()) {
const QString sibling_of_bin =
QDir::cleanPath(bin_dir + "/../lang") + "/";
if (QDir(sibling_of_bin).exists())
return(sibling_of_bin);
}
return(next_to_bin);
}
#else
#ifndef QET_LANG_PATH_RELATIVE_TO_BINARY_PATH
/* the compilation option represents
-1
View File
@@ -2474,7 +2474,6 @@ void QETDiagramEditor::generateTerminalBlock()
exeList << (QETApp::dataDir() + "/binary/qet_tb_generator.exe")
<< (QDir::currentPath() + "/qet_tb_generator.exe")
<< QStandardPaths::findExecutable("qet_tb_generator.exe")
<< (QDir::homePath() + "/Application Data/qet/qet_tb_generator.exe")
<< "qet_tb_generator.exe"
<< "qet_tb_generator"; // from original code: missing ".exe" ???
#elif defined(Q_OS_MACOS)
+5 -1
View File
@@ -80,6 +80,11 @@ class QETDiagramEditor : public QETMainWindow
protected:
bool event(QEvent *) override;
private:
// Declared first so it is initialised before any member whose
// constructor may dispatch a Qt event calling event() (e.g. the
// QActionGroup members below trigger QObject::setParent events).
bool m_first_show = true;
QETDiagramEditor(const QETDiagramEditor &);
void setUpElementsPanel ();
void setUpElementsCollectionWidget();
@@ -253,7 +258,6 @@ class QETDiagramEditor : public QETMainWindow
QUndoGroup undo_group;
AutoNumberingDockWidget *m_autonumbering_dock;
int activeSubWindowIndex;
bool m_first_show = true;
SearchAndReplaceWidget m_search_and_replace_widget;
};
#endif
+42 -8
View File
@@ -43,6 +43,13 @@
static int BACKUP_INTERVAL = 1200000; //interval in ms of backup = 20min
bool QETProject::m_backup_enabled = true;
void QETProject::setBackupEnabled(bool enabled)
{
m_backup_enabled = enabled;
}
/**
@brief QETProject::QETProject
Create a empty project
@@ -130,6 +137,11 @@ QETProject::QETProject(KAutoSaveFile *backup, QObject *parent) :
*/
QETProject::~QETProject()
{
//Wait for any in-flight async crash-recovery backup to finish: the worker
//writes through &m_backup_file, a member that would otherwise be destroyed
//under it (issue #492).
m_backup_future.waitForFinished();
//We block database signal to avoid hundreds of unnecessary emitted signal
//due to deletion (diagram, item, etc...) and as much update made in the not yet deleted things.
m_data_base.blockSignals(true);
@@ -332,6 +344,8 @@ void QETProject::setFilePath(const QString &filepath)
}
#ifdef BUILD_WITHOUT_KF5
#else
//Don't close/re-point the backup file while a backup is still writing it.
m_backup_future.waitForFinished();
if (m_backup_file.isOpen()) {
m_backup_file.close();
}
@@ -1006,16 +1020,30 @@ QETResult QETProject::write()
if (m_file_path.isEmpty())
return(QString("unable to save project to file: no filepath was specified"));
// if the project was opened read-only
// and the file is still non-writable, do not save the project
if (isReadOnly() && !QFileInfo(m_file_path).isWritable())
return(QString("the file %1 was opened read-only and thus will not be written").arg(m_file_path));
// If the project was opened read-only, only refuse when the target
// really can't be written: an existing file that is not writable, or a
// new file (e.g. "Save As" to another location) whose directory is not
// writable. A non-existent file reports isWritable() == false, so the
// old check wrongly blocked saving a read-only project elsewhere.
if (isReadOnly()) {
const QFileInfo file_info(m_file_path);
const bool can_write = file_info.exists()
? file_info.isWritable()
: QFileInfo(file_info.absolutePath()).isWritable();
if (!can_write)
return(QString("the file %1 was opened read-only and thus will not be written").arg(m_file_path));
}
QDomDocument xml_project(toXml());
QString error_message;
if (!QET::writeXmlFile(xml_project, m_file_path, &error_message))
return(error_message);
// The project has just been written to a writable file (e.g. saved to
// a new location with "Save As"), so it is no longer read-only.
if (isReadOnly())
setReadOnly(false);
//title block variables should be updated after file save dialog is confirmed, before file is saved.
m_project_properties.addValue("saveddate", QLocale::system().toString(QDate::currentDate(), QLocale::ShortFormat));
m_project_properties.addValue("saveddate-us", QDate::currentDate().toString("yyyy-MM-dd"));
@@ -1098,7 +1126,7 @@ ElementsLocation QETProject::importElement(ElementsLocation &location)
//Get the path where the element must be imported
QString import_path;
if (location.isFileSystem()) {
import_path = "import/" + location.collectionPath(false);
import_path = "import/" % location.collectionPath(false);
}
else if (location.isProject()) {
if (location.project() == this) {
@@ -1363,7 +1391,7 @@ void QETProject::readProjectXml(QDomDocument &xml_project)
"\n qui est ultérieure à votre version !"
" \n"
"Vous utilisez actuellement QElectroTech en version %2")
.arg(root_elmt.attribute(QStringLiteral("version")), QetVersion::currentVersion().toString() +
.arg(root_elmt.attribute(QStringLiteral("version")), QetVersion::currentVersion().toString() %
tr(".\n Il est alors possible que l'ouverture de tout ou partie de ce "
"document échoue.\n"
"Que désirez vous faire ?"),
@@ -1387,7 +1415,7 @@ void QETProject::readProjectXml(QDomDocument &xml_project)
tr("Avertissement ", "message box title"),
tr("Le projet que vous tentez d'ouvrir est partiellement "
"compatible avec votre version %1 de QElectroTech.\n")
.arg(QetVersion::currentVersion().toString()) +
.arg(QetVersion::currentVersion().toString()) %
tr("Afin de le rendre totalement compatible veuillez ouvrir ce même projet "
"avec la version 0.8, ou 0.80 de QElectroTech et sauvegarder le projet "
"et l'ouvrir à nouveau avec cette version.\n"
@@ -1783,11 +1811,17 @@ void QETProject::addDiagram(Diagram *diagram, int pos)
*/
void QETProject::writeBackup()
{
if (!m_backup_enabled)
return;
#ifdef BUILD_WITHOUT_KF5
#else
# if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) // ### Qt 6: remove
//Don't launch a new backup while the previous one is still writing:
//both would write through &m_backup_file on different threads.
if (m_backup_future.isRunning())
return;
QDomDocument xml_project(toXml());
QtConcurrent::run(
m_backup_future = QtConcurrent::run(
QET::writeToFile,xml_project,&m_backup_file,nullptr);
# else
# if TODO_LIST
+10
View File
@@ -35,6 +35,7 @@
#endif
#include <QHash>
#include <QFuture>
class Diagram;
class ElementsLocation;
@@ -105,6 +106,12 @@ class QETProject : public QObject
QVersionNumber declaredQElectroTechVersion();
void setTitle(const QString &);
/// Enable/disable the asynchronous crash-recovery backup for all
/// projects. Disabled by the headless CLI: the backup write runs on a
/// background thread referencing the project, and a short-lived CLI
/// process can destroy the project before the write finishes (crash).
static void setBackupEnabled(bool enabled);
///DEFAULT PROPERTIES
BorderProperties defaultBorderProperties() const;
void setDefaultBorderProperties(const BorderProperties &);
@@ -241,6 +248,8 @@ class QETProject : public QObject
// attributes
private:
/// When false, writeBackup() is a no-op (set by the headless CLI)
static bool m_backup_enabled;
/// File path this project is saved to
QString m_file_path;
/// Current state of the project
@@ -287,6 +296,7 @@ class QETProject : public QObject
bool m_freeze_new_conductors = false;
QTimer m_save_backup_timer,
m_autosave_timer;
QFuture<bool> m_backup_future;
#ifdef BUILD_WITHOUT_KF5
#else
KAutoSaveFile m_backup_file;
+11 -6
View File
@@ -1756,6 +1756,10 @@ QString TitleBlockTemplate::interpreteVariables(
QStringList TitleBlockTemplate::listOfVariables()
{
QStringList list;
// Match every "%{name}" placeholder. The bare "%name" form can't be
// extracted reliably without the variable list, and templates use the
// braced form, so only that is collected here.
static const QRegularExpression rx(QStringLiteral("%\\{([^}]+)\\}"));
// run through each individual cell
for (int j = 0 ; j < rows_heights_.count() ; ++ j) {
for (int i = 0 ; i < columns_width_.count() ; ++ i) {
@@ -1763,14 +1767,15 @@ QStringList TitleBlockTemplate::listOfVariables()
|| cells_[i][j] -> cell_type
== TitleBlockCell::EmptyCell)
continue;
#if TODO_LIST
#pragma message("@TODO not works on all cases...")
#endif
// TODO: not works on all cases...
list << cells_[i][j] -> value.name().replace("%","");
const QString cell_value = cells_[i][j] -> value.name();
auto it = rx.globalMatch(cell_value);
while (it.hasNext()) {
const QString name = it.next().captured(1);
if (!name.isEmpty() && !list.contains(name))
list << name;
}
}
}
qDebug() << list;
return list;
}
@@ -350,7 +350,7 @@
</widget>
</item>
<item row="7" column="0">
<widget class="QLabel" name="label_11">
<widget class="QLabel" name="label_23">
<property name="text">
<string>Répertoire des Macros utilisateur</string>
</property>
+15 -6
View File
@@ -273,9 +273,15 @@ void ElementInfoWidget::updateUi()
}
// Load the lock status for auto numbering
if (m_element->elementData().m_type == ElementData::Terminal) {
// ... (bestehender Terminal-Code für auto_num_locked und potential_isolating) ...
}
QString lock_value = element_info.value(QStringLiteral("auto_num_locked")).toString();
ui->m_auto_num_locked_cb->setChecked(lock_value == QLatin1String("true"));
// English: Load the potential isolating status from the element information mapping
if (m_potential_isolating_cb) {
QString isolating_value = element_info.value(QStringLiteral("potential_isolating")).toString();
m_potential_isolating_cb->setChecked(isolating_value == QLatin1String("true"));
}
}
// English: Load the BOM exclusion status from the element information mapping
if (m_exclude_from_bom_cb) {
QString exclude_bom_value = element_info.value(QStringLiteral("exclude_from_bom")).toString();
@@ -297,12 +303,11 @@ DiagramContext ElementInfoWidget::currentInfo() const
for (const auto &eipw : qAsConst(m_eipw_list))
{
//add value only if they're something to store
//add value only if they're something to store
if (!eipw->text().isEmpty())
{
QString txt{eipw->text()};
//remove line feed and carriage return
//remove line feed and carriage return
txt.remove(QStringLiteral("\r"));
txt.remove(QStringLiteral("\n"));
info_.addValue(eipw->key(), txt);
@@ -311,12 +316,16 @@ DiagramContext ElementInfoWidget::currentInfo() const
// Save the auto numbering lock status
if (m_element->elementData().m_type == ElementData::Terminal) {
info_.addValue(QStringLiteral("auto_num_locked"), ui->m_auto_num_locked_cb->isChecked() ? QStringLiteral("true") : QStringLiteral("false"));
if (m_potential_isolating_cb) {
info_.addValue(QStringLiteral("potential_isolating"), m_potential_isolating_cb->isChecked() ? QStringLiteral("true") : QStringLiteral("false"));
}
}
if (m_exclude_from_bom_cb) {
info_.addValue(QStringLiteral("exclude_from_bom"), m_exclude_from_bom_cb->isChecked() ? QStringLiteral("true") : QStringLiteral("false"));
}
return info_;
}
/**
+2 -2
View File
@@ -143,7 +143,7 @@ void MasterPropertiesWidget::setElement(Element *element)
disconnect(m_project, SIGNAL(diagramRemoved(QETProject*,Diagram*)),
this, SLOT(diagramWasdeletedFromProject()));
if(Q_LIKELY(element->diagram() && element->diagram()->project()))
if(Q_LIKELY(element->diagram() && element->diagram()->project()))
{
m_project = element->diagram()->project();
connect(m_project, SIGNAL(diagramRemoved(QETProject*,Diagram*)),
@@ -157,7 +157,7 @@ void MasterPropertiesWidget::setElement(Element *element)
disconnect(m_element.data(), &Element::linkedElementChanged,
this, &MasterPropertiesWidget::updateUi);
m_element = element;
m_element = element;
connect(m_element.data(), &Element::linkedElementChanged,
this, &MasterPropertiesWidget::updateUi);
+58 -16
View File
@@ -24,6 +24,7 @@
#include "ui_titleblockpropertieswidget.h"
#include <QMenu>
#include <QSet>
#include <utility>
/**
@@ -162,7 +163,11 @@ void TitleBlockPropertiesWidget::setProperties(
}
ui -> m_tbt_cb -> setCurrentIndex(index);
m_dcw -> setContext(properties.context);
// Show the saved custom values, plus any of the template's custom variables
// that aren't defined yet, so the user only fills in the missing ones (#271).
DiagramContext context = properties.context;
addTemplateVariables(context, index);
m_dcw -> setContext(context);
}
/**
@@ -435,12 +440,15 @@ void TitleBlockPropertiesWidget::updateTemplateList()
}
/**
@brief TitleBlockPropertiesWidget::changeCurrentTitleBlockTemplate
Load the additional field of title block "text"
@brief TitleBlockPropertiesWidget::templateForIndex
@param index : index in the collection-type map (= the template combo index)
@return the TitleBlockTemplate currently selected for that collection, or
nullptr.
*/
void TitleBlockPropertiesWidget::changeCurrentTitleBlockTemplate(int index)
TitleBlockTemplate *TitleBlockPropertiesWidget::templateForIndex(int index) const
{
m_dcw -> clear();
if (index < 0 || index >= m_map_index_to_collection_type.count())
return nullptr;
QET::QetCollection qc = m_map_index_to_collection_type.at(index);
TitleBlockTemplatesCollection *collection = nullptr;
@@ -448,21 +456,55 @@ void TitleBlockPropertiesWidget::changeCurrentTitleBlockTemplate(int index)
if (c -> collection() == qc)
collection = c;
if (!collection) return;
if (!collection) return nullptr;
return collection -> getTemplate(ui -> m_tbt_cb -> currentText());
}
// get template
TitleBlockTemplate *tpl = collection -> getTemplate(ui -> m_tbt_cb -> currentText());
if(tpl != nullptr) {
// get all template fields
QStringList fields = tpl -> listOfVariables();
// set fields to additional_fields_ widget
DiagramContext templateContext;
for(int i =0; i<fields.count(); i++)
templateContext.addValue(fields.at(i), "");
m_dcw -> setContext(templateContext);
/**
@brief TitleBlockPropertiesWidget::addTemplateVariables
Add to @p context every CUSTOM variable used by the currently selected
template that is not already present, with an empty value — so the user
only has to fill in the values instead of declaring the variables (#271).
The standard fields (title, author, date, …) are handled by their own
widgets and are skipped. Existing values in @p context are preserved.
*/
void TitleBlockPropertiesWidget::addTemplateVariables(
DiagramContext &context, int index) const
{
TitleBlockTemplate *tpl = templateForIndex(index);
if (!tpl) return;
// Variables rendered from the dedicated standard-field widgets; they must
// not appear in the "Custom" tab.
static const QSet<QString> reserved {
QStringLiteral("author"), QStringLiteral("date"),
QStringLiteral("title"), QStringLiteral("filename"),
QStringLiteral("plant"), QStringLiteral("locmach"),
QStringLiteral("indexrev"), QStringLiteral("version"),
QStringLiteral("folio"), QStringLiteral("folio-id"),
QStringLiteral("folio-total"), QStringLiteral("auto_page_num"),
QStringLiteral("previous-folio-num"), QStringLiteral("next-folio-num")
};
const QStringList variables = tpl -> listOfVariables();
for (const QString &name : variables) {
if (name.isEmpty() || reserved.contains(name)) continue;
if (!context.contains(name)) context.addValue(name, "");
}
}
/**
@brief TitleBlockPropertiesWidget::changeCurrentTitleBlockTemplate
When the user picks a template, append its missing custom variables to the
"Custom" tab while keeping the values already entered (#271).
*/
void TitleBlockPropertiesWidget::changeCurrentTitleBlockTemplate(int index)
{
DiagramContext context = m_dcw -> context();
addTemplateVariables(context, index);
m_dcw -> setContext(context);
}
/**
@brief TitleBlockPropertiesWidget::on_m_date_now_pb_clicked
Set the date to current date
+3
View File
@@ -30,6 +30,7 @@ class NumerotationContext;
class QETProject;
class QMenu;
class TitleBlockTemplatesCollection;
class TitleBlockTemplate;
namespace Ui {
class TitleBlockPropertiesWidget;
@@ -77,6 +78,8 @@ class TitleBlockPropertiesWidget : public QWidget
void initDialog(const bool &current_date, QETProject *project);
int getIndexFor (const QString &tbt_name,
const QET::QetCollection collection) const;
TitleBlockTemplate *templateForIndex (int index) const;
void addTemplateVariables (DiagramContext &context, int index) const;
private slots:
void editCurrentTitleBlockTemplate();
+32 -21
View File
@@ -151,13 +151,39 @@ void WiringListExport::toCsv()
{
if (!m_project) return;
QDomDocument doc = m_project->toXml();
if (doc.isNull()) {
const QString csv = toCsvString();
if (csv.isEmpty()) {
QMessageBox::warning(m_parent, tr("Erreur"), tr("Impossible de lire la structure en mémoire du projet."));
return;
}
QFileDialog dialog(m_parent);
dialog.setAcceptMode(QFileDialog::AcceptSave);
dialog.setWindowTitle(tr("Exporter le plan de câblage"));
dialog.setDefaultSuffix("csv");
dialog.setNameFilter(tr("Fichiers CSV (*.csv)"));
if (dialog.exec() != QDialog::Accepted) return;
QString fileName = dialog.selectedFiles().first();
QFile file(fileName);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
QMessageBox::warning(m_parent, tr("Erreur"), tr("Impossible d'ouvrir le fichier pour l'écriture."));
return;
}
QTextStream out(&file);
out << csv;
file.close();
QMessageBox::information(m_parent, tr("Export réussi"), tr("Le plan de câblage a été exporté avec succès !"));
}
QString WiringListExport::toCsvString() const
{
if (!m_project) return QString();
QDomDocument doc = m_project->toXml();
if (doc.isNull()) return QString();
QSet<QString> conductorDefinitionTypes;
QDomElement rootElem = doc.documentElement();
QDomElement collection = rootElem.firstChildElement("collection");
@@ -197,21 +223,6 @@ void WiringListExport::toCsv()
}
}
QFileDialog dialog(m_parent);
dialog.setAcceptMode(QFileDialog::AcceptSave);
dialog.setWindowTitle(tr("Exporter le plan de câblage"));
dialog.setDefaultSuffix("csv");
dialog.setNameFilter(tr("Fichiers CSV (*.csv)"));
if (dialog.exec() != QDialog::Accepted) return;
QString fileName = dialog.selectedFiles().first();
QFile file(fileName);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
QMessageBox::warning(m_parent, tr("Erreur"), tr("Impossible d'ouvrir le fichier pour l'écriture."));
return;
}
QMap<QString, ElementInfo> elementsInfo = collectElementsInfo(doc.documentElement());
QList<ConductorData> conductors = collectConductors(doc.documentElement());
@@ -353,7 +364,8 @@ void WiringListExport::toCsv()
return a.terminalname2 < b.terminalname2;
});
QTextStream out(&file);
QString csv;
QTextStream out(&csv);
out << tr("Page", "Wiring list CSV header") << ";"
<< tr("Composant 1", "Wiring list CSV header") << ";"
<< tr("Borne 1", "Wiring list CSV header") << ";"
@@ -376,6 +388,5 @@ void WiringListExport::toCsv()
<< c.function << "\n";
}
file.close();
QMessageBox::information(m_parent, tr("Export réussi"), tr("Le plan de câblage a été exporté avec succès !"));
return csv;
}
+5
View File
@@ -45,6 +45,11 @@ class WiringListExport : public QObject
public:
explicit WiringListExport(QETProject *project, QWidget *parent = nullptr);
void toCsv();
/**
Build the wiring-list CSV and return it as a string (no GUI).
Used by toCsv() and by the headless command-line export.
*/
QString toCsvString() const;
private:
QETProject *m_project;