Files
qelectrotech-source-mirror/sources/container/qetcontainer.cpp
T
ispyisail e89ccf2a40 Open a .qetz from its database, each folio read when built (stage D2)
A .qetz now carries index.sqlite: the project database as QElectroTech
keeps it -- every table but the drawing items, which exist for built
folios only -- with each folio's own data (Diagram::headerXml()) and a
stamp of the schema it was written with (projectDataBase::indexFile(),
QETProject::writeContainerFile(): Save, --resave, --set-titleblock, a
script's and live mode's save). Written when every folio, symbol and wire
has a uuid of its own; a reader that does not know the entry skips it.

With QET_LAZY_FOLIOS=1, a .qetz with an index opens from it: the project
document is joined without its folios (QetContainer::join() with
folio_files), the database and the stores of symbol information and wire
properties are filled from the index (fillFromIndex()), each folio is made
from its own data and keeps the name of its part, read when the folio is
built (Diagram::deferredXml(), QETProject::readFolioPart(),
QetContainer::readFolio() -- pictures and engineering data back from
project.sqlite, QetContainerDb::restoreFolio()). A terminal strip finds its
terminals' folios in the database. Any other .qetz, or one whose index
cannot be used, is read whole as before.

The index is untrusted: refused unless written with this database's
schema, every table read a plain table (not a view), nothing in its schema
run (trusted_schema off); refused, the project is read whole. Checked: an
index of garbage, a wrong stamp, a table made a view -- each refused and
the project saved as a plain save.

The 136-folio f2099 as .qetz opens in 1.8 s (3.65 s read whole, 29 s
building every folio); what is left is mostly its 7 MB of symbol
definitions (0.8 s). The file grows from 2.6 to 5.2 MB, the index
overlapping project.sqlite until D3.

tst_lazyfolios: a .qetz opened from its index builds only the terminal
strip's folios, numbering read as when built, every folio shown in turn
draws and links as when built, the save as when built; fails when a folio
part's engineering data is not put back. Corpus, 342 projects: 340 .qetz
carry an index and open from it (2 have symbols sharing a uuid); saved
back as .qet = eager = master on all 342. Probe on industrial and f2099
.qetz: every folio as eager but the 3 render-noise folios of f2099. In a
real window the .qetz opens and pages building only shown folios and
partners. ctest 96/96 with and without QET_LAZY_FOLIOS=1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:03:43 +13:00

384 lines
14 KiB
C++

/*
Copyright 2006-2026 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 "qetcontainer.h"
#include "qetcontainerdb.h"
#include <QCryptographicHash>
#include <QHash>
#include <QMap>
#include <QSet>
const char QetContainer::MimeType[] = "application/x-qelectrotech-project+zip";
namespace {
const QString PartTag = QStringLiteral("qetz-part");
const QString FileAttribute = QStringLiteral("file");
const QString ImageFileAttribute = QStringLiteral("qetz-file");
void setError(QString *error, const QString &text)
{
if (error) *error = text;
}
//An element on its own, written without adding indentation: the
//whitespace text already in the document (QETProject keeps the symbol
//definitions as they were read, spacing included) must come back as it
//was, or the next save writes it differently. Parts are one line.
QByteArray serialize(const QDomNode &node)
{
QDomDocument document;
document.appendChild(document.createProcessingInstruction(
QStringLiteral("xml"),
QStringLiteral("version=\"1.0\" encoding=\"UTF-8\"")));
document.appendChild(document.importNode(node, true));
return document.toString(-1).toUtf8();
}
//A file name from a name chosen by the user: no folders, nothing a zip
//tool or a file system would read another way.
QString safeSegment(const QString &name, const QString &fallback)
{
QString out;
for (const QChar c : name) {
out += (c.isLetterOrNumber() || c == QLatin1Char('-') || c == QLatin1Char('_')
|| c == QLatin1Char('.') || c == QLatin1Char(' '))
? c : QLatin1Char('_');
}
out = out.trimmed();
while (out.startsWith(QLatin1Char('.'))) out.remove(0, 1);
return out.isEmpty() ? fallback : out;
}
//@p name, or name~2, name~3... if already taken
QString unique(const QString &name, QSet<QString> *taken)
{
QString candidate = name;
for (int n = 2 ; taken->contains(candidate) ; ++n) {
const int dot = name.lastIndexOf(QLatin1Char('.'));
candidate = dot > name.lastIndexOf(QLatin1Char('/'))
? name.left(dot) + QStringLiteral("~%1").arg(n) + name.mid(dot)
: name + QStringLiteral("~%1").arg(n);
}
taken->insert(candidate);
return candidate;
}
QDomElement placeholder(QDomDocument &document, const QString &file)
{
QDomElement part = document.createElement(PartTag);
part.setAttribute(FileAttribute, file);
return part;
}
QList<QDomElement> childElements(const QDomElement &parent, const QString &tag)
{
QList<QDomElement> list;
for (QDomElement e = parent.firstChildElement(tag) ; !e.isNull() ; e = e.nextSiblingElement(tag))
list << e;
return list;
}
//The collection's symbol definitions, each to elements/<categories>/<name>
void splitCollection(QDomDocument &document, const QDomElement &category, const QString &path,
QList<QetZip::Entry> *parts, QSet<QString> *taken)
{
for (QDomElement child = category.firstChildElement() ; !child.isNull() ;
child = child.nextSiblingElement())
{
const QString name = safeSegment(child.attribute(QStringLiteral("name")),
QStringLiteral("unnamed"));
if (child.tagName() == QLatin1String("category")) {
splitCollection(document, child, path + name + QLatin1Char('/'), parts, taken);
} else if (child.tagName() == QLatin1String("element")) {
QDomElement definition = child.firstChildElement(QStringLiteral("definition"));
if (definition.isNull()) continue;
const QString file = unique(path + name, taken);
parts->append({file, serialize(definition), true});
child.replaceChild(placeholder(document, file), definition);
}
}
}
}
/**
@brief QetContainer::parse
Parse @p xml as QETProject::openFile() reads a .qet: a text node made
only of spaces is kept (a title-block value of " ", #973).
*/
bool QetContainer::parse(const QByteArray &xml, QDomDocument *document, QString *error)
{
#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
const auto result = document->setContent(xml, QDomDocument::ParseOption::PreserveSpacingOnlyNodes);
if (!result) {
setError(error, QStringLiteral("line %1: %2").arg(result.errorLine).arg(result.errorMessage));
return false;
}
return true;
#else
QString message;
int line = 0;
if (!document->setContent(xml, &message, &line)) {
setError(error, QStringLiteral("line %1: %2").arg(line).arg(message));
return false;
}
return true;
#endif
}
/**
@brief QetContainer::isZip
A .qetz is recognised by what it holds, not its name: a crash backup
of a .qetz project is plain XML, and a renamed file is still a zip.
*/
bool QetContainer::isZip(const QByteArray &content)
{
return content.startsWith("PK\x03\x04");
}
/**
@brief QetContainer::readDocument
The project document held in @p content: unzipped and joined for a
.qetz, parsed for a .qet.
*/
bool QetContainer::readDocument(const QByteArray &content, QDomDocument *document, QString *error)
{
if (!isZip(content))
return parse(content, document, error);
QList<QetZip::Entry> entries;
return QetZip::fromBytes(content, &entries, error)
&& join(entries, document, error);
}
/**
@brief QetContainer::split
@param project : the project document, as QETProject::toXml() makes it
@param writer : the program writing it, kept in the manifest
@return the entries of the .qetz, mimetype first; empty with @p error
set if @p project is not a project
*/
QList<QetZip::Entry> QetContainer::split(const QDomDocument &project, const QString &writer,
QString *error)
{
QDomDocument document = project.cloneNode(true).toDocument();
QDomElement root = document.documentElement();
if (root.tagName() != QLatin1String("project")) {
setError(error, QStringLiteral("not a project document"));
return {};
}
//The engineering data out of the document, into project.sqlite
const QByteArray database = QetContainerDb::extract(root, error);
if (database.isEmpty())
return {};
QList<QetZip::Entry> parts;
QSet<QString> taken;
//Folios, each with its pictures taken out
int folio = 0;
QMap<QString, QByteArray> pictures; //name -> png, once each
for (QDomElement diagram : childElements(root, QStringLiteral("diagram")))
{
++folio;
for (QDomElement images : childElements(diagram, QStringLiteral("images"))) {
for (QDomElement image : childElements(images, QStringLiteral("image"))) {
const QDomText text = image.firstChild().toText();
if (text.isNull() || image.hasAttribute(ImageFileAttribute)) continue;
const QByteArray base64 = text.data().toLatin1();
const auto decoded = QByteArray::fromBase64Encoding(
base64, QByteArray::AbortOnBase64DecodingErrors);
//Only what comes back exactly leaves the folio
if (!decoded || decoded.decoded.toBase64() != base64 || decoded.decoded.isEmpty())
continue;
const QString name = QStringLiteral("images/%1.%2")
.arg(QString::fromLatin1(QCryptographicHash::hash(
decoded.decoded, QCryptographicHash::Sha256).toHex()),
decoded.decoded.startsWith("\x89PNG") ? QStringLiteral("png")
: QStringLiteral("bin"));
pictures.insert(name, decoded.decoded);
image.removeChild(text);
image.setAttribute(ImageFileAttribute, name);
}
}
const QString uuid = diagram.attribute(QStringLiteral("uuid"));
const QString file = unique(QStringLiteral("folios/%1.xml").arg(
safeSegment(QString(uuid).remove(QLatin1Char('{')).remove(QLatin1Char('}')),
QString::number(folio))),
&taken);
parts.append(QetZip::Entry{file, serialize(diagram), true});
root.replaceChild(placeholder(document, file), diagram);
}
//The project's symbols
for (QDomElement collection : childElements(root, QStringLiteral("collection")))
splitCollection(document, collection, QStringLiteral("elements/"), &parts, &taken);
//Title block templates
for (QDomElement templates : childElements(root, QStringLiteral("titleblocktemplates"))) {
for (QDomElement tbt : childElements(templates, QStringLiteral("titleblocktemplate"))) {
const QString file = unique(QStringLiteral("titleblocks/%1.titleblock").arg(
safeSegment(tbt.attribute(QStringLiteral("name")),
QStringLiteral("unnamed"))),
&taken);
parts.append(QetZip::Entry{file, serialize(tbt), true});
templates.replaceChild(placeholder(document, file), tbt);
}
}
QDomDocument manifest;
QDomElement qetz = manifest.createElement(QStringLiteral("qetz"));
qetz.setAttribute(QStringLiteral("format"), Format);
qetz.setAttribute(QStringLiteral("min-reader"), Format);
if (!writer.isEmpty()) qetz.setAttribute(QStringLiteral("writer"), writer);
manifest.appendChild(qetz);
QList<QetZip::Entry> entries;
entries.append(QetZip::Entry{QStringLiteral("mimetype"), QByteArray(MimeType), false});
entries.append(QetZip::Entry{QStringLiteral("manifest.xml"), serialize(qetz), true});
entries.append(QetZip::Entry{QStringLiteral("project.xml"), serialize(root), true});
entries.append(QetZip::Entry{QStringLiteral("project.sqlite"), database, true});
entries += parts;
for (auto it = pictures.cbegin() ; it != pictures.cend() ; ++it)
entries.append(QetZip::Entry{it.key(), it.value(), false}); //PNG is compressed already
return entries;
}
/**
@brief QetContainer::readFolio
The folio part @p file of the .qetz whose entries are @p files, as
join() puts it in the project: its pictures back as base64 and its
engineering data back from project.sqlite.
*/
bool QetContainer::readFolio(const QHash<QString, QByteArray> &files, const QString &file,
QDomDocument *folio, QString *error)
{
if (!files.contains(file)) {
setError(error, QStringLiteral("part \"%1\" is missing").arg(file));
return false;
}
QString part_error;
if (!parse(files.value(file), folio, &part_error)) {
setError(error, QStringLiteral("%1: %2").arg(file, part_error));
return false;
}
const QDomNodeList images = folio->elementsByTagName(QStringLiteral("image"));
for (int i = 0 ; i < images.size() ; ++i)
{
QDomElement image = images.at(i).toElement();
if (!image.hasAttribute(ImageFileAttribute)) continue;
const QString picture = image.attribute(ImageFileAttribute);
if (!files.contains(picture)) {
setError(error, QStringLiteral("picture \"%1\" is missing").arg(picture));
return false;
}
image.removeAttribute(ImageFileAttribute);
image.insertBefore(folio->createTextNode(QString::fromLatin1(files.value(picture).toBase64())),
image.firstChild());
}
return !files.contains(QStringLiteral("project.sqlite"))
|| QetContainerDb::restoreFolio(folio->documentElement(),
files.value(QStringLiteral("project.sqlite")), error);
}
/**
@brief QetContainer::join
Rebuild in @p project the document split() made @p entries from.
Refused, with @p error set: not a .qetz, made for a later QElectroTech
(the manifest's min-reader), or a part missing or unreadable. Entries
nothing names are ignored, so a later format may add some.
*/
bool QetContainer::join(const QList<QetZip::Entry> &entries, QDomDocument *project, QString *error,
QStringList *folio_files)
{
QHash<QString, QByteArray> files;
for (const QetZip::Entry &entry : entries)
files.insert(entry.name, entry.data);
if (files.value(QStringLiteral("mimetype")) != QByteArray(MimeType)) {
setError(error, QStringLiteral("not a QElectroTech project (.qetz)"));
return false;
}
QDomDocument manifest;
if (!parse(files.value(QStringLiteral("manifest.xml")), &manifest, error))
return false;
bool ok = false;
const int min_reader = manifest.documentElement().attribute(QStringLiteral("min-reader")).toInt(&ok);
if (!ok || min_reader > Format) {
setError(error, QStringLiteral("made by a later QElectroTech (format %1, this one reads up to %2)")
.arg(manifest.documentElement().attribute(QStringLiteral("format")))
.arg(Format));
return false;
}
if (!parse(files.value(QStringLiteral("project.xml")), project, error))
return false;
//Parts, a snapshot first: the list is live and shrinks as they go
const QDomNodeList list = project->elementsByTagName(PartTag);
QList<QDomElement> placeholders;
for (int i = 0 ; i < list.size() ; ++i) placeholders << list.at(i).toElement();
for (QDomElement part : placeholders)
{
const QString file = part.attribute(FileAttribute);
//With @p folio_files, the folios stay parts, read when built
if (folio_files && part.parentNode() == project->documentElement()
&& file.startsWith(QLatin1String("folios/"))) {
if (!files.contains(file)) {
setError(error, QStringLiteral("part \"%1\" is missing").arg(file));
return false;
}
folio_files->append(file);
continue;
}
if (!files.contains(file)) {
setError(error, QStringLiteral("part \"%1\" is missing").arg(file));
return false;
}
QDomDocument document;
QString part_error;
if (!parse(files.value(file), &document, &part_error)) {
setError(error, QStringLiteral("%1: %2").arg(file, part_error));
return false;
}
part.parentNode().replaceChild(project->importNode(document.documentElement(), true), part);
}
//Pictures back to base64, as the image's first child
const QDomNodeList images = project->elementsByTagName(QStringLiteral("image"));
for (int i = 0 ; i < images.size() ; ++i)
{
QDomElement image = images.at(i).toElement();
if (!image.hasAttribute(ImageFileAttribute)) continue;
const QString file = image.attribute(ImageFileAttribute);
if (!files.contains(file)) {
setError(error, QStringLiteral("picture \"%1\" is missing").arg(file));
return false;
}
image.removeAttribute(ImageFileAttribute);
image.insertBefore(project->createTextNode(QString::fromLatin1(files.value(file).toBase64())),
image.firstChild());
}
//The engineering data back from project.sqlite (format 2 on)
if (files.contains(QStringLiteral("project.sqlite"))
&& !QetContainerDb::restore(project->documentElement(),
files.value(QStringLiteral("project.sqlite")), error))
return false;
return true;
}