From fa099115e49faaf5ca34d7530a693e8760baf941 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasunori=20Morishima=EF=BC=88=E7=9B=9B=E5=B3=B6=E5=BA=B7?= =?UTF-8?q?=E5=BE=B3=EF=BC=89?= Date: Thu, 25 Jun 2026 15:12:57 +0000 Subject: [PATCH] Add Keynote reader and writer Add basic support for the Apple Keynote (.key) presentation format, targeting the legacy iWork '09 structure where the presentation is stored as an `index.apxl` XML document inside the `.key` package. The reader and writer cover slides, text placeholders (paragraphs and runs), shape geometry and speaker notes, and are validated with a round-trip test. The modern IWA binary format used by Keynote '13 and later is intentionally out of scope and is safely rejected by canRead(). Both classes follow the existing reader/writer conventions, are registered in IOFactory for automatic resolution, and are documented in the usage pages. Refs #48 --- docs/changes/1.3.0.md | 1 + docs/usage/readers.md | 16 ++ docs/usage/writers.md | 16 ++ src/PhpPresentation/IOFactory.php | 2 +- src/PhpPresentation/Reader/Keynote.php | 228 ++++++++++++++++++ src/PhpPresentation/Writer/Keynote.php | 226 +++++++++++++++++ .../Tests/KeynoteRoundtripTest.php | 192 +++++++++++++++ .../Tests/Reader/KeynoteTest.php | 110 +++++++++ .../Tests/Writer/KeynoteTest.php | 79 ++++++ 9 files changed, 869 insertions(+), 1 deletion(-) create mode 100644 src/PhpPresentation/Reader/Keynote.php create mode 100644 src/PhpPresentation/Writer/Keynote.php create mode 100644 tests/PhpPresentation/Tests/KeynoteRoundtripTest.php create mode 100644 tests/PhpPresentation/Tests/Reader/KeynoteTest.php create mode 100644 tests/PhpPresentation/Tests/Writer/KeynoteTest.php diff --git a/docs/changes/1.3.0.md b/docs/changes/1.3.0.md index de85f15e5..130578c68 100644 --- a/docs/changes/1.3.0.md +++ b/docs/changes/1.3.0.md @@ -5,6 +5,7 @@ ## Enhancements - `phpoffice/phpspreadsheet`: Allow version 5.0 by [@seanlynchwv](http://github.com/seanlynchwv) in [#879](https://github.com/PHPOffice/PHPPresentation/pull/879) - Raised the minimum PHP version to 7.4 (dropped 7.1, 7.2 and 7.3) by [@slayerfx](http://github.com/slayerfx) in [#894](https://github.com/PHPOffice/PHPPresentation/pull/894) +- Keynote: Add basic reader and writer for the Apple Keynote (.key) format by [@yasumorishima](https://github.com/yasumorishima) in [#891](https://github.com/PHPOffice/PHPPresentation/pull/891) ## Bug fixes - Guarded `imagedestroy()` calls behind a PHP version check (no-op since PHP 8.0, deprecated in PHP 8.5) by [@slayerfx](http://github.com/slayerfx) in [#893](https://github.com/PHPOffice/PHPPresentation/pull/893) diff --git a/docs/usage/readers.md b/docs/usage/readers.md index cf8a040f3..f8ec7c1c5 100644 --- a/docs/usage/readers.md +++ b/docs/usage/readers.md @@ -75,6 +75,22 @@ $reader = new PowerPoint2007(); $reader->load(__DIR__ . '/sample.pptx', PowerPoint2007::SKIP_IMAGES); ``` +## Keynote +The name of the reader is `Keynote`. + +This reader provides basic support for the legacy iWork '09 Keynote format, where +the presentation is stored as an `index.apxl` XML document inside the `.key` +package. It reads slides, text placeholders (paragraphs and runs), shape geometry +and speaker notes. The modern IWA binary format used by Keynote '13 and later is +not supported and is rejected by `canRead()`. + +``` php +load(__DIR__ . '/sample.key'); +``` + ## Serialized The name of the reader is `Serialized`. diff --git a/docs/usage/writers.md b/docs/usage/writers.md index 8fccc9729..5d52b0abe 100644 --- a/docs/usage/writers.md +++ b/docs/usage/writers.md @@ -56,6 +56,22 @@ $writer->setZipAdapter(new PclZipAdapter()); $writer->save(__DIR__ . '/sample.pptx'); ``` +## Keynote +The name of the writer is `Keynote`. + +The writer produces a `.key` package following the legacy iWork '09 structure +(an `index.apxl` document). It writes slides, text placeholders (paragraphs and +runs), shape geometry and speaker notes. The output is round-trippable with the +`Keynote` reader; opening it directly in a current Keynote.app is not guaranteed +because of the format generation gap. + +``` php +save(__DIR__ . '/sample.key'); +``` + ## Serialized The name of the writer is `Serialized`. diff --git a/src/PhpPresentation/IOFactory.php b/src/PhpPresentation/IOFactory.php index 147cce4ef..62a84252c 100644 --- a/src/PhpPresentation/IOFactory.php +++ b/src/PhpPresentation/IOFactory.php @@ -36,7 +36,7 @@ class IOFactory * * @var array */ - private static $autoResolveClasses = ['Serialized', 'ODPresentation', 'PowerPoint97', 'PowerPoint2007']; + private static $autoResolveClasses = ['Serialized', 'ODPresentation', 'PowerPoint97', 'PowerPoint2007', 'Keynote']; /** * Create writer. diff --git a/src/PhpPresentation/Reader/Keynote.php b/src/PhpPresentation/Reader/Keynote.php new file mode 100644 index 000000000..71ff6eb48 --- /dev/null +++ b/src/PhpPresentation/Reader/Keynote.php @@ -0,0 +1,228 @@ +fileSupportsUnserializePhpPresentation($pFilename); + } + + /** + * Does a file support being read by this reader? + */ + public function fileSupportsUnserializePhpPresentation(string $pFilename = ''): bool + { + if (!file_exists($pFilename)) { + throw new FileNotFoundException($pFilename); + } + + $oZip = new ZipArchive(); + if (true === $oZip->open($pFilename)) { + $isApxl = is_array($oZip->statName('index.apxl')); + $oZip->close(); + + return $isApxl; + } + + return false; + } + + /** + * Loads a Keynote file. + */ + public function load(string $pFilename, int $flags = 0): PhpPresentation + { + if (!$this->fileSupportsUnserializePhpPresentation($pFilename)) { + throw new InvalidFileFormatException($pFilename, self::class); + } + + $this->loadImages = !((bool) ($flags & self::SKIP_IMAGES)); + + return $this->loadFile($pFilename); + } + + /** + * Loads the file content into a PhpPresentation object. + */ + protected function loadFile(string $pFilename): PhpPresentation + { + $this->oPhpPresentation = new PhpPresentation(); + $this->oPhpPresentation->removeSlideByIndex(); + + $oXMLReader = new XMLReader(); + if (false !== $oXMLReader->getDomFromZip($pFilename, 'index.apxl')) { + $oXMLReader->registerNamespace('key', self::NS_KEY); + $oXMLReader->registerNamespace('sf', self::NS_SF); + $oXMLReader->registerNamespace('sfa', self::NS_SFA); + $this->loadSlides($oXMLReader); + } + + return $this->oPhpPresentation; + } + + /** + * Reads every slide of the presentation. + */ + protected function loadSlides(XMLReader $oXMLReader): void + { + foreach ($oXMLReader->getElements('/key:presentation/key:slide-list/key:slide') as $oNodeSlide) { + if (!$oNodeSlide instanceof DOMElement) { + continue; + } + $oSlide = $this->oPhpPresentation->createSlide(); + $this->loadShapes($oXMLReader, $oNodeSlide, $oSlide); + $this->loadNote($oXMLReader, $oNodeSlide, $oSlide); + } + } + + /** + * Reads every text placeholder of a slide. + */ + protected function loadShapes(XMLReader $oXMLReader, DOMElement $oNodeSlide, Slide $oSlide): void + { + foreach ($oXMLReader->getElements('./key:drawables/key:body-placeholder', $oNodeSlide) as $oNodeShape) { + if (!$oNodeShape instanceof DOMElement) { + continue; + } + $oRichText = $oSlide->createRichTextShape(); + $this->loadGeometry($oXMLReader, $oNodeShape, $oRichText); + $this->loadText($oXMLReader, $oNodeShape, $oRichText); + } + } + + /** + * Reads the position and size of a shape. + */ + protected function loadGeometry(XMLReader $oXMLReader, DOMElement $oNodeShape, RichText $oRichText): void + { + $oNodePosition = $oXMLReader->getElement('./sf:geometry/sf:position', $oNodeShape); + if ($oNodePosition instanceof DOMElement) { + $oRichText->setOffsetX((int) $oNodePosition->getAttributeNS(self::NS_SFA, 'x')); + $oRichText->setOffsetY((int) $oNodePosition->getAttributeNS(self::NS_SFA, 'y')); + } + + $oNodeSize = $oXMLReader->getElement('./sf:geometry/sf:size', $oNodeShape); + if ($oNodeSize instanceof DOMElement) { + $oRichText->setWidth((int) $oNodeSize->getAttributeNS(self::NS_SFA, 'w')); + $oRichText->setHeight((int) $oNodeSize->getAttributeNS(self::NS_SFA, 'h')); + } + } + + /** + * Reads the paragraphs and runs of a text container. + */ + protected function loadText(XMLReader $oXMLReader, DOMElement $oNodeContainer, RichText $oRichText): void + { + $oNodeBody = $oXMLReader->getElement('./sf:text/sf:text-storage/sf:text-body', $oNodeContainer); + if (!$oNodeBody instanceof DOMElement) { + return; + } + + $isFirstParagraph = true; + foreach ($oXMLReader->getElements('./sf:p', $oNodeBody) as $oNodeParagraph) { + if (!$oNodeParagraph instanceof DOMElement) { + continue; + } + $oParagraph = $isFirstParagraph + ? $oRichText->getActiveParagraph() + : $oRichText->createParagraph(); + $isFirstParagraph = false; + + foreach ($oXMLReader->getElements('./sf:span', $oNodeParagraph) as $oNodeSpan) { + if (!$oNodeSpan instanceof DOMElement) { + continue; + } + $oParagraph->createTextRun((string) $oNodeSpan->nodeValue); + } + } + } + + /** + * Reads the speaker note of a slide. + */ + protected function loadNote(XMLReader $oXMLReader, DOMElement $oNodeSlide, Slide $oSlide): void + { + $oNodeNote = $oXMLReader->getElement('./key:notes', $oNodeSlide); + if (!$oNodeNote instanceof DOMElement) { + return; + } + + $oRichText = $oSlide->getNote()->createRichTextShape(); + $this->loadText($oXMLReader, $oNodeNote, $oRichText); + } +} diff --git a/src/PhpPresentation/Writer/Keynote.php b/src/PhpPresentation/Writer/Keynote.php new file mode 100644 index 000000000..0605ac417 --- /dev/null +++ b/src/PhpPresentation/Writer/Keynote.php @@ -0,0 +1,226 @@ +setPhpPresentation($pPhpPresentation ?? new PhpPresentation()); + $this->oDrawingHashTable = new HashTable(); + $this->setZipAdapter(new ZipArchiveAdapter()); + } + + /** + * Save PhpPresentation to file. + */ + public function save(string $pFilename): void + { + if (empty($pFilename)) { + throw new InvalidParameterException('pFilename', ''); + } + + // If $pFilename is php://output or php://stdout, make it a temporary file... + $originalFilename = $pFilename; + if ('php://output' == strtolower($pFilename) || 'php://stdout' == strtolower($pFilename)) { + $pFilename = @tempnam(sys_get_temp_dir(), 'phppttmp'); + if (false === $pFilename || '' == $pFilename) { + $pFilename = $originalFilename; + } + } + + $oZip = $this->getZipAdapter(); + $oZip->open($pFilename); + $oZip->addFromString('index.apxl', $this->writeContent()); + $oZip->close(); + + // If a temporary file was used, copy it to the correct file stream + if ($originalFilename != $pFilename) { + if (false === copy($pFilename, $originalFilename)) { + throw new FileCopyException($pFilename, $originalFilename); + } + if (false === @unlink($pFilename)) { + throw new FileRemoveException($pFilename); + } + } + } + + /** + * Builds the index.apxl document. + */ + protected function writeContent(): string + { + $objWriter = new XMLWriter(XMLWriter::STORAGE_MEMORY); + $objWriter->startDocument('1.0', 'UTF-8'); + + $objWriter->startElement('key:presentation'); + $objWriter->writeAttribute('xmlns:key', self::NS_KEY); + $objWriter->writeAttribute('xmlns:sf', self::NS_SF); + $objWriter->writeAttribute('xmlns:sfa', self::NS_SFA); + $objWriter->writeAttribute('key:version', '92008102400'); + + $objWriter->startElement('key:size'); + $objWriter->writeAttribute('sfa:w', '960'); + $objWriter->writeAttribute('sfa:h', '720'); + $objWriter->endElement(); + + $objWriter->startElement('key:theme-list'); + $objWriter->startElement('key:theme'); + $objWriter->writeAttribute('key:name', 'PHPPresentation'); + $objWriter->endElement(); + $objWriter->endElement(); + + $objWriter->startElement('key:slide-list'); + foreach ($this->getPhpPresentation()->getAllSlides() as $oSlide) { + $this->writeSlide($objWriter, $oSlide); + } + $objWriter->endElement(); + + $objWriter->endElement(); + + return $objWriter->getData(); + } + + /** + * Writes a single slide. + */ + protected function writeSlide(XMLWriter $objWriter, Slide $oSlide): void + { + $objWriter->startElement('key:slide'); + + $objWriter->startElement('key:drawables'); + foreach ($oSlide->getShapeCollection() as $oShape) { + if ($oShape instanceof RichText) { + $this->writeShape($objWriter, $oShape); + } + } + $objWriter->endElement(); + + foreach ($oSlide->getNote()->getShapeCollection() as $oNoteShape) { + if ($oNoteShape instanceof RichText) { + $objWriter->startElement('key:notes'); + $this->writeText($objWriter, $oNoteShape); + $objWriter->endElement(); + + break; + } + } + + $objWriter->endElement(); + } + + /** + * Writes a text placeholder shape, including its geometry. + */ + protected function writeShape(XMLWriter $objWriter, RichText $oShape): void + { + $objWriter->startElement('key:body-placeholder'); + + $objWriter->startElement('sf:geometry'); + $objWriter->startElement('sf:position'); + $objWriter->writeAttribute('sfa:x', (string) $oShape->getOffsetX()); + $objWriter->writeAttribute('sfa:y', (string) $oShape->getOffsetY()); + $objWriter->endElement(); + $objWriter->startElement('sf:size'); + $objWriter->writeAttribute('sfa:w', (string) $oShape->getWidth()); + $objWriter->writeAttribute('sfa:h', (string) $oShape->getHeight()); + $objWriter->endElement(); + $objWriter->endElement(); + + $this->writeText($objWriter, $oShape); + + $objWriter->endElement(); + } + + /** + * Writes the paragraphs and runs of a text container. + */ + protected function writeText(XMLWriter $objWriter, RichText $oShape): void + { + $objWriter->startElement('sf:text'); + $objWriter->startElement('sf:text-storage'); + $objWriter->startElement('sf:text-body'); + foreach ($oShape->getParagraphs() as $oParagraph) { + $objWriter->startElement('sf:p'); + foreach ($oParagraph->getRichTextElements() as $oElement) { + // Only plain text runs are round-trippable; line breaks and other + // element types are not represented in this basic format. + if (!$oElement instanceof Run) { + continue; + } + $objWriter->startElement('sf:span'); + $objWriter->text((string) $oElement->getText()); + $objWriter->endElement(); + } + $objWriter->endElement(); + } + $objWriter->endElement(); + $objWriter->endElement(); + $objWriter->endElement(); + } +} diff --git a/tests/PhpPresentation/Tests/KeynoteRoundtripTest.php b/tests/PhpPresentation/Tests/KeynoteRoundtripTest.php new file mode 100644 index 000000000..1dc560c41 --- /dev/null +++ b/tests/PhpPresentation/Tests/KeynoteRoundtripTest.php @@ -0,0 +1,192 @@ +removeSlideByIndex(); + + $oSlide1 = $oOriginal->createSlide(); + $oShape1 = $oSlide1->createRichTextShape(); + $oShape1->setOffsetX(100); + $oShape1->setOffsetY(200); + $oShape1->setWidth(300); + $oShape1->setHeight(150); + $oParagraph1 = $oShape1->getActiveParagraph(); + $oParagraph1->createTextRun('First '); + $oParagraph1->createTextRun('run'); + $oParagraph2 = $oShape1->createParagraph(); + $oParagraph2->createTextRun('Second paragraph'); + $oSlide1->getNote()->createRichTextShape()->createTextRun('Note text'); + + $oSlide2 = $oOriginal->createSlide(); + $oSlide2->createRichTextShape()->createTextRun('Slide two'); + + $sFilename = tempnam(sys_get_temp_dir(), 'kny') . '.key'; + (new KeynoteWriter($oOriginal))->save($sFilename); + + $oResult = (new KeynoteReader())->load($sFilename); + unlink($sFilename); + + self::assertCount(2, $oResult->getAllSlides()); + + $aShapes = $oResult->getSlide(0)->getShapeCollection(); + self::assertCount(1, $aShapes); + $oResultShape = $aShapes[0]; + self::assertInstanceOf(RichText::class, $oResultShape); + self::assertSame(100, $oResultShape->getOffsetX()); + self::assertSame(200, $oResultShape->getOffsetY()); + self::assertSame(300, $oResultShape->getWidth()); + self::assertSame(150, $oResultShape->getHeight()); + + $aParagraphs = []; + if ($oResultShape instanceof RichText) { + $aParagraphs = $oResultShape->getParagraphs(); + } + self::assertCount(2, $aParagraphs); + self::assertSame('First run', $aParagraphs[0]->getPlainText()); + self::assertSame('Second paragraph', $aParagraphs[1]->getPlainText()); + + $aNoteShapes = $oResult->getSlide(0)->getNote()->getShapeCollection(); + self::assertCount(1, $aNoteShapes); + $oNoteShape = $aNoteShapes[0]; + self::assertInstanceOf(RichText::class, $oNoteShape); + $sNoteText = ''; + if ($oNoteShape instanceof RichText) { + $sNoteText = $oNoteShape->getPlainText(); + } + self::assertSame('Note text', $sNoteText); + + self::assertCount(1, $oResult->getSlide(1)->getShapeCollection()); + } + + public function testRoundtripEmptyPresentation(): void + { + $oOriginal = new PhpPresentation(); + $oOriginal->removeSlideByIndex(); + + $sFilename = tempnam(sys_get_temp_dir(), 'kny') . '.key'; + (new KeynoteWriter($oOriginal))->save($sFilename); + + $oResult = (new KeynoteReader())->load($sFilename); + unlink($sFilename); + + self::assertCount(0, $oResult->getAllSlides()); + } + + /** + * Text runs carrying non-ASCII characters and XML metacharacters must survive + * a write/read cycle unchanged (proper escaping in the writer, unescaping in + * the reader). + */ + public function testRoundtripPreservesUnicodeAndSpecialCharacters(): void + { + $sText = 'Unicode: aeiou 日本語 emoji 😀 - markup & "quotes" \'apostrophe\''; + + $oOriginal = new PhpPresentation(); + $oOriginal->removeSlideByIndex(); + $oOriginal->createSlide()->createRichTextShape()->createTextRun($sText); + + $sFilename = tempnam(sys_get_temp_dir(), 'kny') . '.key'; + (new KeynoteWriter($oOriginal))->save($sFilename); + + $oResult = (new KeynoteReader())->load($sFilename); + unlink($sFilename); + + $aShapes = $oResult->getSlide(0)->getShapeCollection(); + self::assertCount(1, $aShapes); + $oResultShape = $aShapes[0]; + self::assertInstanceOf(RichText::class, $oResultShape); + self::assertSame($sText, $oResultShape->getPlainText()); + } + + /** + * Every text placeholder of a slide is written and read back in order. + */ + public function testRoundtripPreservesMultipleShapesPerSlide(): void + { + $oOriginal = new PhpPresentation(); + $oOriginal->removeSlideByIndex(); + $oSlide = $oOriginal->createSlide(); + $oSlide->createRichTextShape()->createTextRun('Shape one'); + $oSlide->createRichTextShape()->createTextRun('Shape two'); + + $sFilename = tempnam(sys_get_temp_dir(), 'kny') . '.key'; + (new KeynoteWriter($oOriginal))->save($sFilename); + + $oResult = (new KeynoteReader())->load($sFilename); + unlink($sFilename); + + $aShapes = $oResult->getSlide(0)->getShapeCollection(); + self::assertCount(2, $aShapes); + self::assertInstanceOf(RichText::class, $aShapes[0]); + self::assertInstanceOf(RichText::class, $aShapes[1]); + self::assertSame('Shape one', $aShapes[0]->getPlainText()); + self::assertSame('Shape two', $aShapes[1]->getPlainText()); + } + + /** + * Content that the basic format cannot represent is skipped silently: a + * non-text shape produces no output, and line breaks inside a text run are + * dropped while the surrounding runs are preserved. + */ + public function testRoundtripSkipsNonRepresentableContent(): void + { + $oOriginal = new PhpPresentation(); + $oOriginal->removeSlideByIndex(); + $oSlide = $oOriginal->createSlide(); + + // A line is not a text placeholder and must not be written. + $oSlide->createLineShape(10, 10, 100, 100); + + $oShape = $oSlide->createRichTextShape(); + $oParagraph = $oShape->getActiveParagraph(); + $oParagraph->createTextRun('before'); + $oParagraph->createBreak(); + $oParagraph->createTextRun('after'); + + $sFilename = tempnam(sys_get_temp_dir(), 'kny') . '.key'; + (new KeynoteWriter($oOriginal))->save($sFilename); + + $oResult = (new KeynoteReader())->load($sFilename); + unlink($sFilename); + + // Only the text placeholder survives; the line shape is skipped. + $aShapes = $oResult->getSlide(0)->getShapeCollection(); + self::assertCount(1, $aShapes); + $oResultShape = $aShapes[0]; + self::assertInstanceOf(RichText::class, $oResultShape); + // The break is dropped, so the two runs are concatenated. + self::assertSame('beforeafter', $oResultShape->getPlainText()); + } +} diff --git a/tests/PhpPresentation/Tests/Reader/KeynoteTest.php b/tests/PhpPresentation/Tests/Reader/KeynoteTest.php new file mode 100644 index 000000000..dd48b53b3 --- /dev/null +++ b/tests/PhpPresentation/Tests/Reader/KeynoteTest.php @@ -0,0 +1,110 @@ +getActiveSlide(); + $oRichText = $oSlide->createRichTextShape(); + $oRichText->createTextRun('Sample text'); + + $sFilename = tempnam(sys_get_temp_dir(), 'kny') . '.key'; + $oWriter = new KeynoteWriter($oPhpPresentation); + $oWriter->save($sFilename); + + return $sFilename; + } + + public function testCanReadValidFile(): void + { + $sFilename = $this->createSampleFile(); + + $oReader = new Keynote(); + self::assertTrue($oReader->canRead($sFilename)); + + unlink($sFilename); + } + + public function testCanReadRejectsOtherFormats(): void + { + $oReader = new Keynote(); + self::assertFalse($oReader->canRead(PHPPRESENTATION_TESTS_BASE_DIR . '/resources/files/Sample_00_01.pptx')); + } + + public function testCanReadRejectsNonZipFile(): void + { + $oReader = new Keynote(); + self::assertFalse($oReader->canRead(PHPPRESENTATION_TESTS_BASE_DIR . '/resources/files/Sample_00_01.ppt')); + } + + public function testCanReadRejectsDirectory(): void + { + $oReader = new Keynote(); + self::assertFalse($oReader->canRead(PHPPRESENTATION_TESTS_BASE_DIR)); + } + + public function testLoadFileNotExistsThrows(): void + { + $this->expectException(FileNotFoundException::class); + + $oReader = new Keynote(); + $oReader->load(PHPPRESENTATION_TESTS_BASE_DIR . '/resources/files/does_not_exist.key'); + } + + public function testLoadBadFormatThrows(): void + { + $this->expectException(InvalidFileFormatException::class); + + $oReader = new Keynote(); + $oReader->load(PHPPRESENTATION_TESTS_BASE_DIR . '/resources/files/Sample_00_01.pptx'); + } + + public function testLoadReadsContent(): void + { + $sFilename = $this->createSampleFile(); + + $oReader = new Keynote(); + $oPhpPresentation = $oReader->load($sFilename); + unlink($sFilename); + + self::assertCount(1, $oPhpPresentation->getAllSlides()); + $aShapes = $oPhpPresentation->getSlide(0)->getShapeCollection(); + self::assertCount(1, $aShapes); + } +} diff --git a/tests/PhpPresentation/Tests/Writer/KeynoteTest.php b/tests/PhpPresentation/Tests/Writer/KeynoteTest.php new file mode 100644 index 000000000..36e69114a --- /dev/null +++ b/tests/PhpPresentation/Tests/Writer/KeynoteTest.php @@ -0,0 +1,79 @@ +getPhpPresentation()); + } + + public function testSaveCreatesApxlEntry(): void + { + $oPhpPresentation = new PhpPresentation(); + $oSlide = $oPhpPresentation->getActiveSlide(); + $oRichText = $oSlide->createRichTextShape(); + $oRichText->createTextRun('Hello Keynote'); + + $sFilename = tempnam(sys_get_temp_dir(), 'kny') . '.key'; + $oWriter = new Keynote($oPhpPresentation); + $oWriter->save($sFilename); + + self::assertFileExists($sFilename); + + $oZip = new ZipArchive(); + self::assertTrue(true === $oZip->open($sFilename)); + $sContent = $oZip->getFromName('index.apxl'); + $oZip->close(); + + self::assertNotFalse($sContent); + + $oDom = new DOMDocument(); + self::assertTrue($oDom->loadXML((string) $sContent)); + self::assertStringContainsString('Hello Keynote', (string) $sContent); + self::assertStringContainsString('key:presentation', (string) $sContent); + + unlink($sFilename); + } + + public function testSaveWithEmptyFilenameThrows(): void + { + $this->expectException(InvalidParameterException::class); + + $oWriter = new Keynote(new PhpPresentation()); + $oWriter->save(''); + } +}