mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-09-27 12:34:14 +02:00
2322e6fd12
Second of @scorpio810's review notes on #630: exportWiring() follows the existing CLI exporters (QTextStream, plain QFile). On Qt6 the output is UTF-8, so encoding is fine. Once #830 is in, it could optionally reuse BomExport::writeCsv() to get a BOM, which Excel needs to detect UTF-8 when opening the file directly, and an atomic write. Done directly rather than waiting on #830, since neither half depends on it and both are small. The bytes were already UTF-8; what was missing is the mark that tells Excel so. Opening a .csv without one, Excel falls back to the local 8-bit codepage and mangles any accented element label -- the common case for this project's users. QSaveFile replaces QFile so a failure part-way through leaves the previous file intact instead of a truncated one. QSaveFile is already the codebase's pattern for this (QET::writeToFile, qet.cpp:664). Verified on perceuse.qet: output now starts ef bb bf, the header follows intact, all 156 rows are preserved, and the file parses as utf-8-sig. Pointing the exporter at a missing project leaves an existing target file untouched, where before it would have been truncated. Left the other CLI exporters alone. They share the same pattern, but changing exportBom() would add a BOM to output that existing scripts already consume, which is a behaviour change outside the scope of this review note. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
930 lines
28 KiB
C++
930 lines
28 KiB
C++
/*
|
|
Copyright 2006-2025 The QElectroTech Team
|
|
This file is part of QElectroTech.
|
|
|
|
QElectroTech is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 2 of the License, or
|
|
(at your option) any later version.
|
|
|
|
QElectroTech is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
#include "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 <QSaveFile>
|
|
#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-wiring", "wiring"},
|
|
{"--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.
|
|
/// @p showTerminals: paint terminal markers (red stroke + blue docking
|
|
/// dot) and terminal names, as the interactive editor does. Off by
|
|
/// default — Terminal::paint() draws them whenever the diagram's
|
|
/// drawTerminals()/drawTerminalNames() flags are set (default true, so
|
|
/// headless export used to ship them as editor UI); the GUI export
|
|
/// dialog already defaults to clearing them via
|
|
/// Diagram::applyProperties(). Pass true (--show-terminals) to keep
|
|
/// them, e.g. to visually debug an unconnected pin.
|
|
void renderDiagram(Diagram *diagram, QPainter &painter, const QRectF &target,
|
|
bool showTerminals = false)
|
|
{
|
|
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();
|
|
const bool was_drawing_guides = diagram->displayGuides();
|
|
const bool was_drawing_terminals = diagram->drawTerminals();
|
|
const bool was_drawing_terminal_names = diagram->drawTerminalNames();
|
|
diagram->setDisplayGrid(false);
|
|
diagram->setDisplayGuides(false);
|
|
diagram->setDrawTerminals(showTerminals);
|
|
diagram->setDrawTerminalNames(showTerminals);
|
|
diagram->render(&painter, target, source, Qt::KeepAspectRatio);
|
|
diagram->setDisplayGrid(was_drawing_grid);
|
|
diagram->setDisplayGuides(was_drawing_guides);
|
|
diagram->setDrawTerminals(was_drawing_terminals);
|
|
diagram->setDrawTerminalNames(was_drawing_terminal_names);
|
|
}
|
|
|
|
int exportPdf(QETProject &project, const QString &output,
|
|
bool showTerminals = false)
|
|
{
|
|
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, showTerminals);
|
|
|
|
// 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, bool showTerminals = false)
|
|
{
|
|
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()),
|
|
showTerminals);
|
|
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()),
|
|
showTerminals);
|
|
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;
|
|
#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
|
|
if (const auto result = doc.setContent(&file); !result) {
|
|
file.close();
|
|
out << "FAIL " << path << " (XML error line "
|
|
<< result.errorLine << ": " << result.errorMessage << ")\n";
|
|
return 2;
|
|
}
|
|
#else
|
|
QString error;
|
|
int line = 0;
|
|
if (!doc.setContent(&file, &error, &line)) {
|
|
file.close();
|
|
out << "FAIL " << path << " (XML error line "
|
|
<< line << ": " << error << ")\n";
|
|
return 2;
|
|
}
|
|
#endif
|
|
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;
|
|
}
|
|
|
|
/// From-to wiring list: one row per conductor, each endpoint resolved to its
|
|
/// element label and terminal name.
|
|
///
|
|
/// Reads wiring_list_view out of the project database. --export-cables produces
|
|
/// the same logical list from the document XML instead, and the two are meant
|
|
/// to agree: running both and diffing them is a direct check that the database
|
|
/// still describes the project, which is otherwise only observable through the
|
|
/// GUI.
|
|
int exportWiring(QETProject &project, const QString &output)
|
|
{
|
|
// The project database is built lazily; force a (re)build before querying.
|
|
project.dataBase()->updateDB();
|
|
|
|
static const QStringList columns {
|
|
"wire_number", "from_element_label", "from_terminal",
|
|
"to_element_label", "to_terminal", "diagram_position", "conductor_uuid"
|
|
};
|
|
|
|
QSqlQuery query = project.dataBase()->newQuery(
|
|
"SELECT " % columns.join(", ") %
|
|
" FROM wiring_list_view"
|
|
//Wire numbers are text, so a plain sort puts "10" before "9".
|
|
//Numeric ones first, ordered by value; anything non-numeric after,
|
|
//ordered as text. The trailing wire_number keeps ties stable.
|
|
" ORDER BY diagram_position,"
|
|
" CASE WHEN wire_number GLOB '[0-9]*' THEN 0 ELSE 1 END,"
|
|
" CAST(wire_number AS INTEGER),"
|
|
" wire_number");
|
|
if (!query.exec()) {
|
|
err << "Wiring list 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;
|
|
}
|
|
|
|
//Written through QSaveFile so a failure part-way leaves the previous
|
|
//file intact rather than a truncated one, and with a UTF-8 byte order
|
|
//mark: without it Excel opens a .csv as the local 8-bit codepage and
|
|
//mangles any accented element label. Qt writes UTF-8 by default, so
|
|
//the bytes were already right -- the mark is what tells Excel so.
|
|
QSaveFile file(output);
|
|
if (!file.open(QIODevice::WriteOnly)) {
|
|
err << "Cannot open '" << output << "' for writing.\n";
|
|
return 1;
|
|
}
|
|
static const char utf8_bom[] = "\xEF\xBB\xBF";
|
|
file.write(utf8_bom, 3);
|
|
file.write(csv.toUtf8());
|
|
if (!file.commit()) {
|
|
err << "Cannot write '" << output << "': " << file.errorString() << "\n";
|
|
return 1;
|
|
}
|
|
out << "Exported " << rows << " conductor(s) -> " << output << "\n";
|
|
return 0;
|
|
}
|
|
|
|
/// 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)
|
|
{
|
|
// --show-terminals is a standalone switch (not tied to a position),
|
|
// so pull it out before the positional project/output arguments are
|
|
// collected below.
|
|
QStringList filtered = args;
|
|
const bool showTerminals = filtered.removeAll("--show-terminals") > 0;
|
|
|
|
QString flag;
|
|
QStringList rest;
|
|
for (int i = 0; i < filtered.size(); ++i) {
|
|
if (exportFlags().contains(filtered.at(i))) {
|
|
flag = filtered.at(i);
|
|
for (int j = i + 1; j < filtered.size(); ++j)
|
|
rest << filtered.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, showTerminals);
|
|
if (format == "cables" || format == "wires")
|
|
return exportCsv(project, format, output);
|
|
if (format == "bom")
|
|
return exportBom(project, output);
|
|
if (format == "wiring")
|
|
return exportWiring(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, showTerminals);
|
|
}
|
|
|
|
} // namespace CLIExport
|