diff --git a/CHANGELOG.md b/CHANGELOG.md index fa3f293..ed527b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 43ef442..068ddad 100644 --- a/README.md +++ b/README.md @@ -2330,7 +2330,7 @@ echo (new PowerPointToMarkdown($path, new Options(mediaDirectory: null)))->conve ```text --- -title: "Untitled Presentation" +title: "One" author: "markdown-office" --- @@ -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 diff --git a/examples/build.php b/examples/build.php index 8b92085..8318f47 100644 --- a/examples/build.php +++ b/examples/build.php @@ -11,7 +11,22 @@ * examples/out, and a PNG of every slide beside them in examples/out//. */ -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; diff --git a/src/Reverse/DeckWriter.php b/src/Reverse/DeckWriter.php index 1912f86..b736323 100644 --- a/src/Reverse/DeckWriter.php +++ b/src/Reverse/DeckWriter.php @@ -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) { } @@ -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; @@ -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 $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, ]; @@ -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. diff --git a/tests/Readme/examples-build.php b/tests/Readme/examples-build.php new file mode 100644 index 0000000..21a782e --- /dev/null +++ b/tests/Readme/examples-build.php @@ -0,0 +1,75 @@ + ['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([]); +}); diff --git a/tests/Unit/dead-code.php b/tests/Unit/dead-code.php index 5ba6eb5..ddc8b39 100644 --- a/tests/Unit/dead-code.php +++ b/tests/Unit/dead-code.php @@ -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 () { diff --git a/tests/Unit/reverse.php b/tests/Unit/reverse.php index db4ef44..0f74482 100644 --- a/tests/Unit/reverse.php +++ b/tests/Unit/reverse.php @@ -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"); @@ -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 () {