Skip to content

Commit e4df1bd

Browse files
committed
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
1 parent bb4cc77 commit e4df1bd

9 files changed

Lines changed: 783 additions & 1 deletion

File tree

docs/changes/1.3.0.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
## Enhancements
66
- `phpoffice/phpspreadsheet`: Allow version 5.0 by [@seanlynchwv](http://github.com/seanlynchwv) in [#879](https://github.com/PHPOffice/PHPPresentation/pull/879)
77
- 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)
8+
- 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)
89

910
## Bug fixes
1011
- 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)

docs/usage/readers.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,22 @@ $reader = new PowerPoint2007();
7575
$reader->load(__DIR__ . '/sample.pptx', PowerPoint2007::SKIP_IMAGES);
7676
```
7777

78+
## Keynote
79+
The name of the reader is `Keynote`.
80+
81+
This reader provides basic support for the legacy iWork '09 Keynote format, where
82+
the presentation is stored as an `index.apxl` XML document inside the `.key`
83+
package. It reads slides, text placeholders (paragraphs and runs), shape geometry
84+
and speaker notes. The modern IWA binary format used by Keynote '13 and later is
85+
not supported and is rejected by `canRead()`.
86+
87+
``` php
88+
<?php
89+
90+
$reader = IOFactory::createReader('Keynote');
91+
$reader->load(__DIR__ . '/sample.key');
92+
```
93+
7894
## Serialized
7995
The name of the reader is `Serialized`.
8096

docs/usage/writers.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,22 @@ $writer->setZipAdapter(new PclZipAdapter());
5656
$writer->save(__DIR__ . '/sample.pptx');
5757
```
5858

59+
## Keynote
60+
The name of the writer is `Keynote`.
61+
62+
The writer produces a `.key` package following the legacy iWork '09 structure
63+
(an `index.apxl` document). It writes slides, text placeholders (paragraphs and
64+
runs), shape geometry and speaker notes. The output is round-trippable with the
65+
`Keynote` reader; opening it directly in a current Keynote.app is not guaranteed
66+
because of the format generation gap.
67+
68+
``` php
69+
<?php
70+
71+
$writer = IOFactory::createWriter($oPhpPresentation, 'Keynote');
72+
$writer->save(__DIR__ . '/sample.key');
73+
```
74+
5975
## Serialized
6076
The name of the writer is `Serialized`.
6177

src/PhpPresentation/IOFactory.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ class IOFactory
3636
*
3737
* @var array<int, string>
3838
*/
39-
private static $autoResolveClasses = ['Serialized', 'ODPresentation', 'PowerPoint97', 'PowerPoint2007'];
39+
private static $autoResolveClasses = ['Serialized', 'ODPresentation', 'PowerPoint97', 'PowerPoint2007', 'Keynote'];
4040

4141
/**
4242
* Create writer.
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
<?php
2+
3+
/**
4+
* This file is part of PHPPresentation - A pure PHP library for reading and writing
5+
* presentations documents.
6+
*
7+
* PHPPresentation is free software distributed under the terms of the GNU Lesser
8+
* General Public License version 3 as published by the Free Software Foundation.
9+
*
10+
* For the full copyright and license information, please read the LICENSE
11+
* file that was distributed with this source code. For the full list of
12+
* contributors, visit https://github.com/PHPOffice/PHPPresentation/contributors.
13+
*
14+
* @see https://github.com/PHPOffice/PHPPresentation
15+
*
16+
* @license http://www.gnu.org/licenses/lgpl.txt LGPL version 3
17+
*/
18+
19+
declare(strict_types=1);
20+
21+
namespace PhpOffice\PhpPresentation\Reader;
22+
23+
use DOMElement;
24+
use PhpOffice\Common\XMLReader;
25+
use PhpOffice\PhpPresentation\Exception\FileNotFoundException;
26+
use PhpOffice\PhpPresentation\Exception\InvalidFileFormatException;
27+
use PhpOffice\PhpPresentation\PhpPresentation;
28+
use PhpOffice\PhpPresentation\Shape\RichText;
29+
use PhpOffice\PhpPresentation\Slide;
30+
use ZipArchive;
31+
32+
/**
33+
* Reader for the Apple Keynote (.key) presentation format.
34+
*
35+
* This reader targets the legacy iWork '09 single-file structure, where the
36+
* presentation is described by an `index.apxl` XML document stored inside the
37+
* Keynote package (a Zip archive). The modern IWA binary format (Keynote '13 and
38+
* later) is intentionally not handled and is safely rejected by canRead().
39+
*/
40+
class Keynote implements ReaderInterface
41+
{
42+
/**
43+
* Keynote namespace.
44+
*
45+
* @var string
46+
*/
47+
public const NS_KEY = 'http://developer.apple.com/namespaces/keynote2';
48+
49+
/**
50+
* iWork shared namespace.
51+
*
52+
* @var string
53+
*/
54+
public const NS_SF = 'http://developer.apple.com/namespaces/sf';
55+
56+
/**
57+
* iWork shared abstract namespace.
58+
*
59+
* @var string
60+
*/
61+
public const NS_SFA = 'http://developer.apple.com/namespaces/sfa';
62+
63+
/**
64+
* Output object.
65+
*
66+
* @var PhpPresentation
67+
*/
68+
protected $oPhpPresentation;
69+
70+
/**
71+
* Should the images be loaded?
72+
*
73+
* @var bool
74+
*/
75+
protected $loadImages = true;
76+
77+
/**
78+
* Can the current reader read the file?
79+
*/
80+
public function canRead(string $pFilename): bool
81+
{
82+
return $this->fileSupportsUnserializePhpPresentation($pFilename);
83+
}
84+
85+
/**
86+
* Does a file support being read by this reader?
87+
*/
88+
public function fileSupportsUnserializePhpPresentation(string $pFilename = ''): bool
89+
{
90+
if (!file_exists($pFilename)) {
91+
throw new FileNotFoundException($pFilename);
92+
}
93+
94+
$oZip = new ZipArchive();
95+
if (true === $oZip->open($pFilename)) {
96+
$isApxl = is_array($oZip->statName('index.apxl'));
97+
$oZip->close();
98+
99+
return $isApxl;
100+
}
101+
102+
return false;
103+
}
104+
105+
/**
106+
* Loads a Keynote file.
107+
*/
108+
public function load(string $pFilename, int $flags = 0): PhpPresentation
109+
{
110+
if (!$this->fileSupportsUnserializePhpPresentation($pFilename)) {
111+
throw new InvalidFileFormatException($pFilename, self::class);
112+
}
113+
114+
$this->loadImages = !((bool) ($flags & self::SKIP_IMAGES));
115+
116+
return $this->loadFile($pFilename);
117+
}
118+
119+
/**
120+
* Loads the file content into a PhpPresentation object.
121+
*/
122+
protected function loadFile(string $pFilename): PhpPresentation
123+
{
124+
$this->oPhpPresentation = new PhpPresentation();
125+
$this->oPhpPresentation->removeSlideByIndex();
126+
127+
$oXMLReader = new XMLReader();
128+
if (false !== $oXMLReader->getDomFromZip($pFilename, 'index.apxl')) {
129+
$oXMLReader->registerNamespace('key', self::NS_KEY);
130+
$oXMLReader->registerNamespace('sf', self::NS_SF);
131+
$oXMLReader->registerNamespace('sfa', self::NS_SFA);
132+
$this->loadSlides($oXMLReader);
133+
}
134+
135+
return $this->oPhpPresentation;
136+
}
137+
138+
/**
139+
* Reads every slide of the presentation.
140+
*/
141+
protected function loadSlides(XMLReader $oXMLReader): void
142+
{
143+
foreach ($oXMLReader->getElements('/key:presentation/key:slide-list/key:slide') as $oNodeSlide) {
144+
if (!$oNodeSlide instanceof DOMElement) {
145+
continue;
146+
}
147+
$oSlide = $this->oPhpPresentation->createSlide();
148+
$this->loadShapes($oXMLReader, $oNodeSlide, $oSlide);
149+
$this->loadNote($oXMLReader, $oNodeSlide, $oSlide);
150+
}
151+
}
152+
153+
/**
154+
* Reads every text placeholder of a slide.
155+
*/
156+
protected function loadShapes(XMLReader $oXMLReader, DOMElement $oNodeSlide, Slide $oSlide): void
157+
{
158+
foreach ($oXMLReader->getElements('./key:drawables/key:body-placeholder', $oNodeSlide) as $oNodeShape) {
159+
if (!$oNodeShape instanceof DOMElement) {
160+
continue;
161+
}
162+
$oRichText = $oSlide->createRichTextShape();
163+
$this->loadGeometry($oXMLReader, $oNodeShape, $oRichText);
164+
$this->loadText($oXMLReader, $oNodeShape, $oRichText);
165+
}
166+
}
167+
168+
/**
169+
* Reads the position and size of a shape.
170+
*/
171+
protected function loadGeometry(XMLReader $oXMLReader, DOMElement $oNodeShape, RichText $oRichText): void
172+
{
173+
$oNodePosition = $oXMLReader->getElement('./sf:geometry/sf:position', $oNodeShape);
174+
if ($oNodePosition instanceof DOMElement) {
175+
$oRichText->setOffsetX((int) $oNodePosition->getAttributeNS(self::NS_SFA, 'x'));
176+
$oRichText->setOffsetY((int) $oNodePosition->getAttributeNS(self::NS_SFA, 'y'));
177+
}
178+
179+
$oNodeSize = $oXMLReader->getElement('./sf:geometry/sf:size', $oNodeShape);
180+
if ($oNodeSize instanceof DOMElement) {
181+
$oRichText->setWidth((int) $oNodeSize->getAttributeNS(self::NS_SFA, 'w'));
182+
$oRichText->setHeight((int) $oNodeSize->getAttributeNS(self::NS_SFA, 'h'));
183+
}
184+
}
185+
186+
/**
187+
* Reads the paragraphs and runs of a text container.
188+
*/
189+
protected function loadText(XMLReader $oXMLReader, DOMElement $oNodeContainer, RichText $oRichText): void
190+
{
191+
$oNodeBody = $oXMLReader->getElement('./sf:text/sf:text-storage/sf:text-body', $oNodeContainer);
192+
if (!$oNodeBody instanceof DOMElement) {
193+
return;
194+
}
195+
196+
$isFirstParagraph = true;
197+
foreach ($oXMLReader->getElements('./sf:p', $oNodeBody) as $oNodeParagraph) {
198+
if (!$oNodeParagraph instanceof DOMElement) {
199+
continue;
200+
}
201+
$oParagraph = $isFirstParagraph
202+
? $oRichText->getActiveParagraph()
203+
: $oRichText->createParagraph();
204+
$isFirstParagraph = false;
205+
206+
foreach ($oXMLReader->getElements('./sf:span', $oNodeParagraph) as $oNodeSpan) {
207+
if (!$oNodeSpan instanceof DOMElement) {
208+
continue;
209+
}
210+
$oParagraph->createTextRun((string) $oNodeSpan->nodeValue);
211+
}
212+
}
213+
}
214+
215+
/**
216+
* Reads the speaker note of a slide.
217+
*/
218+
protected function loadNote(XMLReader $oXMLReader, DOMElement $oNodeSlide, Slide $oSlide): void
219+
{
220+
$oNodeNote = $oXMLReader->getElement('./key:notes', $oNodeSlide);
221+
if (!$oNodeNote instanceof DOMElement) {
222+
return;
223+
}
224+
225+
$oRichText = $oSlide->getNote()->createRichTextShape();
226+
$this->loadText($oXMLReader, $oNodeNote, $oRichText);
227+
}
228+
}

0 commit comments

Comments
 (0)