Fix a .qetz that cannot be opened after only a folio's title changed

With a .qetz and lazy folios (both the default), changing only the
title, author, date or revision of a folio holding a picture, while that
folio was not displayed, then saving, gave a .qetz that refused to open:
'picture "images/<sha>.png" is missing'.

Such a folio is saved from its items as read, with its new header
(Diagram::deferredXmlWithHeader()). Read from a .qetz, its <image> names
its picture's file (qetz-file) instead of holding the picture, and
QetContainer::split() takes out only pictures held inline: the file was
never written, while the folio still named it. The folio now gets its
pictures written in, from the bytes the project keeps
(QETProject::containerPicture()), as a built folio writes them.

On master, a picture on folio 2 of grafcet, iso_sfc_example, 741,
tremie_vibrante or Projet_vierge is lost this way; found on three forum
projects by comparing lazy saves with saves of every folio built. Saving
as .qet was not affected.

New tst_qetzheaderpictures (grafcet.qet: picture on folio 2, saved as
.qetz; its title changed with folios built when needed; reopened with
its picture and title): fails without this change ("picture ... is
missing"). ctest 107/107.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-10-16 09:32:13 +13:00
parent 973417a694
commit b4bbd17933
5 changed files with 123 additions and 0 deletions
+7
View File
@@ -208,6 +208,13 @@ QString QetContainer::pictureFile(const QDomElement &image)
return image.attribute(ImageFileAttribute); return image.attribute(ImageFileAttribute);
} }
void QetContainer::inlinePicture(QDomElement image, const QByteArray &bytes)
{
image.removeAttribute(ImageFileAttribute);
image.insertBefore(image.ownerDocument().createTextNode(QString::fromLatin1(bytes.toBase64())),
image.firstChild());
}
/** /**
@brief QetContainer::ownUuids @brief QetContainer::ownUuids
@return true if every folio, symbol and wire of @p project has a uuid @return true if every folio, symbol and wire of @p project has a uuid
+3
View File
@@ -84,6 +84,9 @@ namespace QetContainer
QString *error = nullptr); QString *error = nullptr);
/// The file join() left the picture of @p image in, or an empty string /// The file join() left the picture of @p image in, or an empty string
QString pictureFile(const QDomElement &image); QString pictureFile(const QDomElement &image);
/// @p image, which names its picture's file (pictureFile()), given
/// the picture itself, @p bytes, as a document read whole has it
void inlinePicture(QDomElement image, const QByteArray &bytes);
/// Take what QElectroTech computes out of a folio (format 4) /// Take what QElectroTech computes out of a folio (format 4)
void dropComputed(QDomElement diagram, bool terminal_lists); void dropComputed(QDomElement diagram, bool terminal_lists);
+11
View File
@@ -881,6 +881,17 @@ QDomElement Diagram::deferredXmlWithHeader(QDomDocument &document)
for (QDomElement child = items.firstChildElement() ; !child.isNull() ; child = child.nextSiblingElement()) for (QDomElement child = items.firstChildElement() ; !child.isNull() ; child = child.nextSiblingElement())
if (!header_children.contains(child.tagName())) if (!header_children.contains(child.tagName()))
folio.appendChild(document.importNode(child, true)); folio.appendChild(document.importNode(child, true));
//Its pictures written in, as building it writes them. A folio read
//from a .qetz names its pictures' files instead, which the next
//.qetz written from this document would leave out: that project
//then refused to open, "picture ... is missing" (F118).
for (QDomElement image : QET::findInDomElement(folio, QStringLiteral("images"), QStringLiteral("image"))) {
const QString file = QetContainer::pictureFile(image);
if (file.isEmpty() || !m_project) continue;
const QByteArray bytes = m_project->containerPicture(file);
if (!bytes.isEmpty())
QetContainer::inlinePicture(image, bytes);
}
return folio; return folio;
} }
+12
View File
@@ -943,6 +943,18 @@ if(QET_HAS_SCRIPTING)
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\"" "QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\""
"QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"")
# A .qetz folio whose title only changed keeps its pictures when saved
# with folios built when needed (F118), on examples/grafcet.qet.
add_executable(
tst_qetzheaderpictures
tst_qetzheaderpictures.cpp)
add_test(NAME tst_qetzheaderpictures COMMAND tst_qetzheaderpictures)
add_dependencies(tst_qetzheaderpictures qelectrotech)
target_link_libraries(tst_qetzheaderpictures PRIVATE Qt::Test)
target_compile_definitions(tst_qetzheaderpictures PRIVATE
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\""
"QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"")
# qet.elementTerminals() lists each terminal's uuid and qet.terminalIndex() # qet.elementTerminals() lists each terminal's uuid and qet.terminalIndex()
# turns element uuid + terminal uuid back into the index the conductor # turns element uuid + terminal uuid back into the index the conductor
# calls take, on examples/perceuse.qet (two terminals at one point). # calls take, on examples/perceuse.qet (two terminals at one point).
+90
View File
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include <QtTest>
#include <QDir>
#include <QFile>
#include <QProcess>
#include <QProcessEnvironment>
#include <QTemporaryDir>
// A folio of a .qetz whose title only changed is saved from its items as
// read, not built (Diagram::deferredXmlWithHeader()). Those items name
// their pictures' files in the .qetz, which the new .qetz left out: the
// project saved then refused to open, 'picture "images/..." is missing'
// (FINDINGS F118). Runs scripts through the real binary's --run on a copy
// of examples/grafcet.qet given a picture on its second folio.
class tst_qetzheaderpictures : public QObject
{
Q_OBJECT
QTemporaryDir m_dir;
// Run @p script on @p project; its output
QString run(const QString &script, const QString &project, bool every_folio_built)
{
const QString path = m_dir.filePath(QStringLiteral("script.js"));
QFile f(path);
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) return QString();
f.write(script.toUtf8());
f.close();
const QString home = m_dir.filePath(QStringLiteral("home"));
QDir().mkpath(home);
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen"));
env.insert(QStringLiteral("QET_ENABLE_SCRIPTING"), QStringLiteral("1"));
env.insert(QStringLiteral("HOME"), home);
env.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/config"));
env.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/data"));
env.insert(QStringLiteral("TMPDIR"), m_dir.path());
if (every_folio_built)
env.insert(QStringLiteral("QET_EAGER_FOLIOS"), QStringLiteral("1"));
else
env.remove(QStringLiteral("QET_EAGER_FOLIOS"));
QProcess proc;
proc.setProcessEnvironment(env);
proc.start(QStringLiteral(QET_TEST_BINARY_PATH), {QStringLiteral("--run"), path, project});
proc.waitForFinished(120000);
return QString::fromUtf8(proc.readAllStandardOutput() + proc.readAllStandardError());
}
private slots:
void initTestCase()
{
QVERIFY(m_dir.isValid());
QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH)));
}
void titleChangeKeepsPictures()
{
// A 1x1 PNG
const QString png = m_dir.filePath(QStringLiteral("dot.png"));
QFile p(png);
QVERIFY(p.open(QIODevice::WriteOnly));
p.write(QByteArray::fromBase64("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="));
p.close();
const QString with_picture = m_dir.filePath(QStringLiteral("picture.qetz"));
const QString titled = m_dir.filePath(QStringLiteral("titled.qetz"));
QString out = run(QStringLiteral("qet.log('ADDED ' + qet.addImage(1, '%1', 100, 100));"
"qet.save('%2');").arg(png, with_picture),
QStringLiteral(QET_EXAMPLES_DIR "/grafcet.qet"), true);
QVERIFY2(out.contains(QStringLiteral("ADDED 0")), qPrintable(out));
QVERIFY(QFile::exists(with_picture));
// Only the title of the folio with the picture, folios built
// when needed
out = run(QStringLiteral("qet.log('SET ' + qet.setFolioProperty(1, 'title', 'Edited'));"
"qet.save('%1');").arg(titled), with_picture, false);
QVERIFY2(out.contains(QStringLiteral("SET true")), qPrintable(out));
QVERIFY(QFile::exists(titled));
// Opens, with its picture and the new title
out = run(QStringLiteral("qet.log('PICTURES ' + qet.images(1).length);"
"qet.log('TITLE ' + qet.folioTitle(1));"), titled, true);
QVERIFY2(out.contains(QStringLiteral("PICTURES 1")), qPrintable(out));
QVERIFY2(out.contains(QStringLiteral("TITLE Edited")), qPrintable(out));
}
};
QTEST_APPLESS_MAIN(tst_qetzheaderpictures)
#include "tst_qetzheaderpictures.moc"