Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,47 @@ All notable changes to this project are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- **A deck read back into Markdown carries no title it never had.** PHPPresentation gives
every deck the title `Untitled Presentation`, and the frontmatter carried it as though
somebody had written it: a deck without a title came back with one, and drawing that
Markdown again put a title placeholder and the words "Untitled Presentation" on a slide
that never had a title.

The title in the frontmatter is the deck's own where the deck has one, and the first
slide's title where it has not — the frontmatter is the first slide's, and a `title:` in
it is that slide's title. So a deck whose title is on its slide and nowhere else keeps
it, and a deck with no title at all comes back with no `title:` line.

It is the first slide's title and not a later one's, the way it is in the other
direction: a `title:` in the first slide's frontmatter is that slide's title and wins
over its own heading. So a deck whose second slide has a title and whose first does not
gains no title.

- **A deck read back into Markdown carries no author it never had.** PHPPresentation gives
every deck the author `Unknown Creator`, and that is the library's own default rather
than a name somebody wrote: the frontmatter carried it as though somebody had, and the
deck drawn from that Markdown was made by "Unknown Creator".

The author in the frontmatter is the deck's own, and a deck that carries the default
carries no author, so none is written.

- **The slides example says what it needs when the library is not beside it.** The
`mdword` skill bundle carries this half's gallery as `examples/slides/` and ships no
`vendor/`, so `php examples/slides/build.php` died with a PHP fatal error that said
nothing about what was missing.

It says what the build needs and stops:

> This build needs the library itself: put a `vendor/autoload.php` in this directory's
> parent, or run `composer install` in the checkout it came from.

The Word half's build has said this for a while, and the two halves ship side by side in
one bundle, so they fail the same way.

## [0.4.0] - 2026-11-04

### Added
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2330,7 +2330,7 @@ echo (new PowerPointToMarkdown($path, new Options(mediaDirectory: null)))->conve

```text
---
title: "Untitled Presentation"
title: "One"
author: "markdown-office"
---

Expand All @@ -2340,8 +2340,13 @@ Text.
```

The frontmatter carries what the deck said about itself — its title, its author and its
description — because a deck's own properties are the only thing about it that is not on a
slide.
description. The title is the deck's own where it has one, and the first slide's where it
has not: PHPPresentation gives every deck the title `Untitled Presentation`, and that is
its own default rather than a title somebody wrote. The frontmatter is the first slide's,
and a `title:` in it is that slide's title — so a deck whose title is on its slide and
nowhere else keeps it. The author is the deck's own, and the name PHPPresentation gives a
deck that carries none — `Unknown Creator` — is a name nobody wrote, so a deck carrying it
carries no author.

What comes back is the text and the structure. What does not is the design: the type sizes,
the colours and the geometry live in the theme and the master, and neither travels to a new
Expand Down
17 changes: 16 additions & 1 deletion examples/build.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,22 @@
* examples/out, and a PNG of every slide beside them in examples/out/<deck>/.
*/

require_once __DIR__ . '/../vendor/autoload.php';
$autoload = __DIR__ . '/../vendor/autoload.php';

// The build drives the library's own API, so it needs the library. The `mdword` skill
// carries a copy of this directory as `examples/slides/`, with the sources and the
// templates but not the library, where a missing `require` would say nothing.
if (!is_file($autoload)) {
fwrite(
STDERR,
"This build needs the library itself: put a `vendor/autoload.php` in this directory's parent,"
. " or run `composer install` in the checkout it came from.\n",
);

exit(1);
}

require_once $autoload;
require_once __DIR__ . '/decks.php';

use MarkdownPresentation\Document\DeckParser;
Expand Down
52 changes: 49 additions & 3 deletions src/Reverse/DeckWriter.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ final class DeckWriter
*/
private const INDENT = ' ';

/**
* The title PHPPresentation gives a deck that carries none: its own default
* rather than a title somebody wrote.
*/
private const DEFAULT_TITLE = 'Untitled Presentation';

/**
* The author PHPPresentation gives a deck that carries none: its own default
* rather than a name somebody wrote.
*/
private const DEFAULT_AUTHOR = 'Unknown Creator';

public function __construct(private readonly Options $options)
{
}
Expand All @@ -49,7 +61,7 @@ public function write(array $slides, DocumentProperties $properties, ?string $th
if ($index === 0) {
// The deck's own frontmatter is the first slide's, so the first slide's own
// settings are written into it rather than beside it.
$frontmatter = $this->frontmatter($properties, $theme, $slide->frontmatter);
$frontmatter = $this->frontmatter($properties, $theme, $slide->frontmatter, $slide->title);

if ($frontmatter !== '') {
$body = $frontmatter . "\n" . $body;
Expand Down Expand Up @@ -105,20 +117,26 @@ private function settings(array $settings): string
* the theme it was drawn in rather than in the library's own, and the design of a deck is
* most of what a deck is.
*
* The title is the deck's own where it has one and the first slide's where it has
* not, which is what {@see self::title()} decides.
*
* @param string|null $theme The name of the theme the deck was drawn in, when it is one
* a deck can name again.
* @param array<string, string|int|bool> $own The first slide's own settings, which are
* written into the deck's own block with them.
* @param string|null $slideTitle The title of the first slide, which stands in for the
* deck's own title where the properties carry none.
*/
private function frontmatter(
DocumentProperties $properties,
?string $theme = null,
array $own = [],
?string $slideTitle = null,
): string {
$values = [
'title' => $properties->getTitle(),
'title' => $this->title($properties, $slideTitle),
'theme' => $theme,
'author' => $properties->getCreator(),
'author' => $this->author($properties),
'description' => $properties->getDescription(),
...$own,
];
Expand Down Expand Up @@ -150,6 +168,34 @@ private function frontmatter(
return "---\n" . implode("\n", $lines) . "\n---\n";
}

/**
* The title the deck's frontmatter carries, or null where it carries none.
*
* The properties' title is the deck's own, unless it is {@see self::DEFAULT_TITLE},
* which says nothing about a deck that carries it. The first slide's title stands in
* for it then: the frontmatter is the first slide's, and a `title:` in it is that
* slide's title, so a deck whose title is on its slide and nowhere else would lose it.
*/
private function title(DocumentProperties $properties, ?string $slideTitle): ?string
{
$title = $properties->getTitle();

return $title === self::DEFAULT_TITLE ? $slideTitle : $title;
}

/**
* The author the deck's frontmatter carries, or null where it carries none.
*
* The properties' author is the deck's own, unless it is {@see self::DEFAULT_AUTHOR}:
* a name nobody wrote, which the Markdown would carry as though somebody had.
*/
private function author(DocumentProperties $properties): ?string
{
$author = $properties->getCreator();

return $author === self::DEFAULT_AUTHOR ? null : $author;
}

/**
* A YAML scalar, quoted so that a colon or a hash in the value is not read
* as syntax.
Expand Down
75 changes: 75 additions & 0 deletions tests/Readme/examples-build.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

declare(strict_types=1);

use MarkdownPresentation\Tests\Support\Doctest;
use MarkdownPresentation\Tests\Support\Scratch;

/*
* The slides example, run the way the skill bundle runs it.
*
* The bundle carries this half's gallery as `examples/slides/` and ships no `vendor/`, so
* the build has to say what it needs rather than die with a fatal error that says nothing.
* The copy is made into a tree of its own rather than run where it lies, because the
* library *is* beside it here: a build that found this repository's `vendor/` would carry
* on and build the decks, and the test would be about nothing.
*/

/**
* The build of the slides example, run in a tree that holds no library.
*
* @return array{0: int, 1: string, 2: string, 3: string} The exit code, what it said on the
* standard output, what it said on the error output, and the tree it ran in.
*/
function slidesBuildWithoutLibrary(): array
{
$directory = Scratch::directoryPath('bundle');

mkdir($directory . '/examples', 0o777, true);

foreach (['build.php', 'decks.php'] as $file) {
copy(Doctest::root() . '/examples/' . $file, $directory . '/examples/' . $file);
}

$process = proc_open(
[PHP_BINARY, $directory . '/examples/build.php'],
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$pipes,
);

if (!is_resource($process)) {
throw new RuntimeException('The build could not be started.');
}

$out = (string) stream_get_contents($pipes[1]);
$errors = (string) stream_get_contents($pipes[2]);

fclose($pipes[1]);
fclose($pipes[2]);

return [proc_close($process), $out, $errors, $directory];
}

it('says what the slides example needs when the library is not beside it', function () {
[$status, $out, $errors] = slidesBuildWithoutLibrary();

expect($status)->toBe(1)
->and($errors)->toContain(
'This build needs the library itself: put a `vendor/autoload.php` in this directory\'s parent,'
. ' or run `composer install` in the checkout it came from.',
)
// On the error output and not on the standard one, and not as a PHP failure: the
// build is there to replace a warning about a missing file.
->and($out)->not->toContain('This build needs the library itself')
->and($errors)->not->toContain('Warning')
->and($errors)->not->toContain('Fatal error');
});

it('stops the slides example before it builds anything', function () {
[$status, , , $directory] = slidesBuildWithoutLibrary();

// The build says what it needs and stops there: nothing is written.
expect($status)->toBe(1)
->and(is_dir($directory . '/examples/out'))->toBeFalse()
->and(glob($directory . '/examples/*.pptx') ?: [])->toBe([]);
});
4 changes: 4 additions & 0 deletions tests/Unit/dead-code.php
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,10 @@ function pagesWithClaim(string $claim): array
// `styles.yml` is about. The phrase is the old claim, and the README has to be able to
// say what the halves part at without saying it.
'a theme directory serves one half' => 'a theme directory serves one half',

// The frontmatter was said to carry what is not on a slide, but a deck's title can be
// on its first slide and nowhere else, and the frontmatter is the first slide's.
'the frontmatter carries only what is not on a slide' => 'the only thing about it that is not on a slide',
]);

it('has no decorative dividers in it', function () {
Expand Down
69 changes: 67 additions & 2 deletions tests/Unit/reverse.php
Original file line number Diff line number Diff line change
Expand Up @@ -891,6 +891,66 @@ static function (DocumentProperties $properties): void {
->and(converted($path))->toContain('author: "Fabian"');
});

it('writes no title into the frontmatter of a deck that has none', function () {
// PHPPresentation's own default, not a title somebody wrote: a deck carrying it says
// nothing about its title.
$path = writtenDeck("Text only.\n");

expect(converted($path))->not->toContain('title:');
});

it('writes the title of the first slide into the frontmatter of a deck that has none', function () {
// The frontmatter is the first slide's, and a `title:` in it is that slide's title.
$path = writtenDeck("# One\n\nText.\n");

expect(converted($path))->toContain('title: "One"');
});

it('takes the title of the first slide and not the title of a later one', function () {
// Only the first slide's title stands in: a deck whose second slide has a title and
// whose first does not gains none.
$path = writtenDeck("Text only.\n\n---\n\n# Two\n");

expect(converted($path))->not->toContain('title:');
});

it('writes the title of the deck into the frontmatter where it has one', function () {
// The deck's own title is the deck's, and it wins over the first slide's.
$path = deckWithShapes(
static function (AbstractSlide $slide): void {
placeholderShape($slide, 'title', 'What broke');
},
static function (DocumentProperties $properties): void {
$properties->setTitle('Harbour lights');
},
);

expect(converted($path))->toContain('title: "Harbour lights"');
});

it('writes no author into the frontmatter of a deck that has none', function () {
// PHPPresentation's own default, not a name somebody wrote: the Markdown carried it
// as though somebody had.
$path = deckWithShapes(
static function (AbstractSlide $slide): void {
placeholderShape($slide, 'title', 'What broke');
},
);

expect(converted($path))->not->toContain('author:');
});

it('draws the same slide again when the Markdown it came from is drawn again', function () {
// The round trip has to be a round trip: the Markdown a deck is read back into, drawn
// again, has to be the deck it came from.
$first = writtenDeck("# One\n\nText.\n");
$back = converted($first);
$second = writtenDeck($back);

expect(slideTexts($second))->toBe(slideTexts($first))
->and(converted($second))->toBe($back);
});

it('refuses a package with more parts than the limit allows', function () {
$path = writtenDeck("# One\n");

Expand Down Expand Up @@ -1523,15 +1583,20 @@ static function (DocumentProperties $properties): void {

it('writes nothing for a block that holds nothing', function () {
$writer = new DeckWriter(new Options(mediaDirectory: null));
$properties = new DocumentProperties();
$properties->setTitle('A deck');

expect($writer->write([
$markdown = $writer->write([
new Slide(0, null, [
Block::paragraph([]),
Block::list([Block::item([Block::paragraph([])])]),
Block::table([]),
Block::image(new Image('A dot', '', '')),
]),
], new DocumentProperties()))->toContain('---');
], $properties);

// The deck's own frontmatter and nothing after it.
expect(trim(str_replace('---', '', $markdown)))->toBe('title: "A deck"');
});

it('leaves a picture with nothing in it out', function () {
Expand Down
Loading