diff --git a/src/cli.cpp b/src/cli.cpp index 0c1588a..e9f3a46 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -249,12 +249,18 @@ int render(const QStringList &arguments) { output = name; else if (QFileInfo(output).isDir()) output = QDir(output).filePath(name); + const QString target = QFileInfo(output).canonicalFilePath(); + if (!target.isEmpty() && target == QFileInfo(deck.path()).canonicalFilePath()) + return fail("Choose an output other than the presentation itself."); QDir().mkpath(QFileInfo(output).absolutePath()); // A slide with problems still renders, with the problems on a banner, so it can be looked at. const auto problems = slideProblemsAt(deck, parsed, number - 1); QString warning; const QImage image = paint(deck, number - 1, width, &warning); - if (!image.save(output, "PNG")) + // Written beside and renamed over, so an existing file, or a hard link to the + // presentation, is replaced rather than overwritten in place. + QSaveFile file(output); + if (!file.open(QIODevice::WriteOnly) || !image.save(&file, "PNG") || !file.commit()) return fail("Could not save " + output); int errors = 0; QJsonArray list; diff --git a/src/deck.cpp b/src/deck.cpp index 79daf5c..4a78fb8 100644 --- a/src/deck.cpp +++ b/src/deck.cpp @@ -1607,10 +1607,12 @@ bool Deck::renderImages(const QString &directory, int width, bool powerPoint) { } slides.append(entry); } - QFile manifest(directory + "/slides.json"); - if (!manifest.open(QIODevice::WriteOnly)) + QSaveFile manifest(directory + "/slides.json"); + const QByteArray json = QJsonDocument(QJsonObject{{"title", title()}, {"slides", slides}}).toJson(); + if (!manifest.open(QIODevice::WriteOnly) || manifest.write(json) != json.size() || !manifest.commit()) { + setStatus("Could not save slides.json: " + manifest.errorString()); return false; - manifest.write(QJsonDocument(QJsonObject{{"title", title()}, {"slides", slides}}).toJson()); + } return true; } bool Deck::exportPptx(const QString &path) { diff --git a/tests/test_cli.py b/tests/test_cli.py index 927c207..c604f77 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -63,6 +63,21 @@ def test_save_applies_a_theme_and_exits(self): self.assertTrue(self.hype(self.deck, '--theme', 'paper', '--save', code=1).stderr.strip()) self.assertEqual(self.deck.read_text(), unfinished) + def test_render_never_overwrites_the_presentation_and_reports_its_manifest(self): + self.write(DECK) + link = self.root / 'link.md' + link.symlink_to(self.deck) + for output in (self.deck, link): + self.assertIn('presentation itself', self.hype('render', self.deck, '--slide', 1, '-o', output, code=1).stderr) + self.assertEqual(self.deck.read_text(), DECK) + alias = self.root / 'alias.md' + os.link(self.deck, alias) + self.hype('render', self.deck, '--slide', 1, '-o', alias) + self.assertEqual(self.deck.read_text(), DECK) + self.png_size(alias) + (self.root / 'slides/slides.json').mkdir(parents=True) + self.assertIn('slides.json', self.hype('render', self.deck, '-o', self.root / 'slides', code=1).stderr) + def test_check_reports_every_problem_with_slide_and_line(self): self.write(DECK + '\n---\n\n![](missing.png)\n\n---\n\n![bogus=1](gone.png)\n\n---\n') report = json.loads(self.hype('check', self.deck, '--json', code=1).stdout)