Improve ZIP interface for better mime detection

The ZipHelper interface is now more generic and allow single files to be added.
It supports adding uncompressed files (for PHP7+), which is required to have the mime detection magic work with ODS files.
Also fixed a few issues with the created ODS file (thanks to https://odf-validator.rhcloud.com/)
This commit is contained in:
Adrien Loison 2015-12-05 14:46:57 -08:00
parent 44d72d8245
commit ed0e8f79cc
4 changed files with 194 additions and 68 deletions

View File

@ -11,97 +11,170 @@ namespace Box\Spout\Writer\Common\Helper;
class ZipHelper class ZipHelper
{ {
const ZIP_EXTENSION = '.zip'; const ZIP_EXTENSION = '.zip';
const CONTENT_TYPES_XML_FILE_NAME = '[Content_Types].xml';
/** Controls what to do when trying to add an existing file */
const EXISTING_FILES_SKIP = 'skip';
const EXISTING_FILES_OVERWRITE = 'overwrite';
/** @var string Path of the folder where the zip file will be created */
protected $tmpFolderPath;
/** @var \ZipArchive The ZipArchive instance */
protected $zip;
/** /**
* Zips the root folder and streams the contents of the zip into the given stream * @param string $tmpFolderPath Path of the temp folder where the zip file will be created
*
* @param string $folderPath Path to the folder to be zipped
* @param resource $streamPointer Pointer to the stream to copy the zip
* @return void
*/ */
public function zipFolderAndCopyToStream($folderPath, $streamPointer) public function __construct($tmpFolderPath)
{ {
$zipFilePath = $this->getZipFilePath($folderPath); $this->tmpFolderPath = $tmpFolderPath;
$this->zipFolder($folderPath, $zipFilePath); }
$this->copyZipToStream($zipFilePath, $streamPointer);
/**
* Returns the already created ZipArchive instance or
* creates one if none exists.
*
* @return \ZipArchive
*/
protected function createOrGetZip()
{
if (!isset($this->zip)) {
$this->zip = new \ZipArchive();
$zipFilePath = $this->getZipFilePath();
$this->zip->open($zipFilePath, \ZipArchive::CREATE|\ZipArchive::OVERWRITE);
}
return $this->zip;
} }
/** /**
* @param string $folderPathToZip Path to the folder to be zipped
* @return string Path where the zip file of the given folder will be created * @return string Path where the zip file of the given folder will be created
*/ */
public function getZipFilePath($folderPathToZip) public function getZipFilePath()
{ {
return $folderPathToZip . self::ZIP_EXTENSION; return $this->tmpFolderPath . self::ZIP_EXTENSION;
} }
/** /**
* Zips the given folder * Adds the given file, located under the given root folder to the archive.
* The file will be compressed.
* *
* @param string $folderPath Path of the folder to be zipped * Example of use:
* @param string $destinationPath Path where the zip file will be created * addFileToArchive('/tmp/xlsx/foo', 'bar/baz.xml');
* => will add the file located at '/tmp/xlsx/foo/bar/baz.xml' in the archive, but only as 'bar/baz.xml'
*
* @param string $rootFolderPath Path of the root folder that will be ignored in the archive tree.
* @param string $localFilePath Path of the file to be added, under the root folder
* @param string|void $existingFileMode Controls what to do when trying to add an existing file
* @return void * @return void
*/ */
public function zipFolder($folderPath, $destinationPath) public function addFileToArchive($rootFolderPath, $localFilePath, $existingFileMode = self::EXISTING_FILES_OVERWRITE)
{ {
$zip = new \ZipArchive(); $this->addFileToArchiveWithCompressionMethod(
if ($zip->open($destinationPath, \ZipArchive::CREATE)) { $rootFolderPath,
$this->addFolderToZip($zip, $folderPath); $localFilePath,
$zip->close(); $existingFileMode,
} \ZipArchive::CM_DEFAULT
);
} }
/** /**
* @param \ZipArchive $zip * Adds the given file, located under the given root folder to the archive.
* @param string $folderPath Path of the folder to add to the zip * The file will NOT be compressed.
*
* Example of use:
* addUncompressedFileToArchive('/tmp/xlsx/foo', 'bar/baz.xml');
* => will add the file located at '/tmp/xlsx/foo/bar/baz.xml' in the archive, but only as 'bar/baz.xml'
*
* @param string $rootFolderPath Path of the root folder that will be ignored in the archive tree.
* @param string $localFilePath Path of the file to be added, under the root folder
* @param string|void $existingFileMode Controls what to do when trying to add an existing file
* @return void * @return void
*/ */
protected function addFolderToZip($zip, $folderPath) public function addUncompressedFileToArchive($rootFolderPath, $localFilePath, $existingFileMode = self::EXISTING_FILES_OVERWRITE)
{ {
$folderRealPath = $this->getNormalizedRealPath($folderPath) . '/'; $this->addFileToArchiveWithCompressionMethod(
$itemIterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($folderPath, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST); $rootFolderPath,
$localFilePath,
$existingFileMode,
\ZipArchive::CM_STORE
);
}
// In order to have the file's mime type detected properly, items need to be /**
// sorted in a particular order... * Adds the given file, located under the given root folder to the archive.
$itemsInfo = iterator_to_array($itemIterator); * The file will NOT be compressed.
usort($itemsInfo, [$this, 'sortItemsForCorrectMimeTypeDetection']); *
* Example of use:
* addUncompressedFileToArchive('/tmp/xlsx/foo', 'bar/baz.xml');
* => will add the file located at '/tmp/xlsx/foo/bar/baz.xml' in the archive, but only as 'bar/baz.xml'
*
* @param string $rootFolderPath Path of the root folder that will be ignored in the archive tree.
* @param string $localFilePath Path of the file to be added, under the root folder
* @param string $existingFileMode Controls what to do when trying to add an existing file
* @param int $compressionMethod The compression method
* @return void
*/
protected function addFileToArchiveWithCompressionMethod($rootFolderPath, $localFilePath, $existingFileMode, $compressionMethod)
{
$zip = $this->createOrGetZip();
foreach ($itemsInfo as $itemInfo) { if (!$this->shouldSkipFile($zip, $localFilePath, $existingFileMode)) {
$itemRealPath = $this->getNormalizedRealPath($itemInfo->getPathname()); $normalizedFullFilePath = $this->getNormalizedRealPath($rootFolderPath . '/' . $localFilePath);
$itemLocalPath = str_replace($folderRealPath, '', $itemRealPath); $zip->addFile($normalizedFullFilePath, $localFilePath);
if ($itemInfo->isFile()) { if (self::canChooseCompressionMethod()) {
$zip->addFile($itemRealPath, $itemLocalPath); $zip->setCompressionName($localFilePath, $compressionMethod);
} else if ($itemInfo->isDir()) {
$zip->addEmptyDir($itemLocalPath);
} }
} }
} }
/** /**
* On order to have the file's mime type detected properly, files need to be added * @return bool Whether it is possible to choose the desired compression method to be used
* to the zip file in a particular order.
* [Content_Types].xml and files located in "xl" folder should be zipped first.
*
* @param \SplFileInfo $itemInfo1 First item to compare
* @param \SplFileInfo $itemInfo2 Second item to compare
* @return int
*/ */
protected function sortItemsForCorrectMimeTypeDetection($itemInfo1, $itemInfo2) public static function canChooseCompressionMethod()
{ {
// Have the "[Content_Types].xml" file be first // setCompressionName() is a PHP7+ method...
if ($itemInfo1->getFilename() === self::CONTENT_TYPES_XML_FILE_NAME) { return (method_exists(new \ZipArchive(), 'setCompressionName'));
return -1; }
} else if ($itemInfo2->getFilename() === self::CONTENT_TYPES_XML_FILE_NAME) {
return 1; /**
} else { * @param string $folderPath Path to the folder to be zipped
// Then make sure the files in the "xl" folder will go next * @param string|void $existingFileMode Controls what to do when trying to add an existing file
// by sorting items in reverse alphabetical order * @return void
return strcmp($itemInfo2->getRealPath(), $itemInfo1->getRealPath()); */
public function addFolderToArchive($folderPath, $existingFileMode = self::EXISTING_FILES_OVERWRITE)
{
$zip = $this->createOrGetZip();
$folderRealPath = $this->getNormalizedRealPath($folderPath) . '/';
$itemIterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($folderPath, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST);
foreach ($itemIterator as $itemInfo) {
$itemRealPath = $this->getNormalizedRealPath($itemInfo->getPathname());
$itemLocalPath = str_replace($folderRealPath, '', $itemRealPath);
if ($itemInfo->isFile() && !$this->shouldSkipFile($zip, $itemLocalPath, $existingFileMode)) {
$zip->addFile($itemRealPath, $itemLocalPath);
}
} }
} }
/**
* @param \ZipArchive $zip
* @param string $itemLocalPath
* @param string $existingFileMode
* @return bool Whether the file should be added to the archive or skipped
*/
protected function shouldSkipFile($zip, $itemLocalPath, $existingFileMode)
{
// Skip files if:
// - EXISTING_FILES_SKIP mode chosen
// - File already exists in the archive
return ($existingFileMode === self::EXISTING_FILES_SKIP && $zip->locateName($itemLocalPath) !== false);
}
/** /**
* Returns canonicalized absolute pathname, containing only forward slashes. * Returns canonicalized absolute pathname, containing only forward slashes.
* *
@ -114,16 +187,30 @@ class ZipHelper
return str_replace(DIRECTORY_SEPARATOR, '/', $realPath); return str_replace(DIRECTORY_SEPARATOR, '/', $realPath);
} }
/**
* Closes the archive and copies it into the given stream
*
* @param resource $streamPointer Pointer to the stream to copy the zip
* @return void
*/
public function closeArchiveAndCopyToStream($streamPointer)
{
$zip = $this->createOrGetZip();
$zip->close();
unset($this->zip);
$this->copyZipToStream($streamPointer);
}
/** /**
* Streams the contents of the zip file into the given stream * Streams the contents of the zip file into the given stream
* *
* @param string $zipFilePath Path to the zip file
* @param resource $pointer Pointer to the stream to copy the zip * @param resource $pointer Pointer to the stream to copy the zip
* @return void * @return void
*/ */
protected function copyZipToStream($zipFilePath, $pointer) protected function copyZipToStream($pointer)
{ {
$zipFilePointer = fopen($zipFilePath, 'r'); $zipFilePointer = fopen($this->getZipFilePath(), 'r');
stream_copy_to_stream($zipFilePointer, $pointer); stream_copy_to_stream($zipFilePointer, $pointer);
fclose($zipFilePointer); fclose($zipFilePointer);
} }

View File

@ -103,14 +103,12 @@ class FileSystemHelper extends \Box\Spout\Common\Helper\FileSystemHelper
protected function createManifestFile() protected function createManifestFile()
{ {
$manifestXmlFileContents = <<<EOD $manifestXmlFileContents = <<<EOD
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8"?>
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"> <manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.2">
<manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.spreadsheet"/> <manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.spreadsheet"/>
<manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/> <manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/> <manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="meta.xml" manifest:media-type="text/xml"/> <manifest:file-entry manifest:full-path="meta.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="mimetype" manifest:media-type="text/plain"/>
<manifest:file-entry manifest:full-path="META-INF/manifest.xml" manifest:media-type="text/xml"/>
</manifest:manifest> </manifest:manifest>
EOD; EOD;
@ -265,10 +263,17 @@ EOD;
*/ */
public function zipRootFolderAndCopyToStream($streamPointer) public function zipRootFolderAndCopyToStream($streamPointer)
{ {
$zipHelper = new ZipHelper(); $zipHelper = new ZipHelper($this->rootFolder);
$zipHelper->zipFolderAndCopyToStream($this->rootFolder, $streamPointer);
// In order to have the file's mime type detected properly, files need to be added
// to the zip file in a particular order.
// @see http://www.jejik.com/articles/2010/03/how_to_correctly_create_odf_documents_using_zip/
$zipHelper->addUncompressedFileToArchive($this->rootFolder, self::MIMETYPE_FILE_NAME);
$zipHelper->addFolderToArchive($this->rootFolder, ZipHelper::EXISTING_FILES_SKIP);
$zipHelper->closeArchiveAndCopyToStream($streamPointer);
// once the zip is copied, remove it // once the zip is copied, remove it
$this->deleteFile($zipHelper->getZipFilePath($this->rootFolder)); $this->deleteFile($zipHelper->getZipFilePath());
} }
} }

View File

@ -353,10 +353,19 @@ EOD;
*/ */
public function zipRootFolderAndCopyToStream($streamPointer) public function zipRootFolderAndCopyToStream($streamPointer)
{ {
$zipHelper = new ZipHelper(); $zipHelper = new ZipHelper($this->rootFolder);
$zipHelper->zipFolderAndCopyToStream($this->rootFolder, $streamPointer);
// In order to have the file's mime type detected properly, files need to be added
// to the zip file in a particular order.
// "[Content_Types].xml" then at least 2 files located in "xl" folder should be zipped first.
$zipHelper->addFileToArchive($this->rootFolder, self::CONTENT_TYPES_XML_FILE_NAME);
$zipHelper->addFileToArchive($this->rootFolder, self::XL_FOLDER_NAME . '/' . self::WORKBOOK_XML_FILE_NAME);
$zipHelper->addFileToArchive($this->rootFolder, self::XL_FOLDER_NAME . '/' . self::STYLES_XML_FILE_NAME);
$zipHelper->addFolderToArchive($this->rootFolder, ZipHelper::EXISTING_FILES_SKIP);
$zipHelper->closeArchiveAndCopyToStream($streamPointer);
// once the zip is copied, remove it // once the zip is copied, remove it
$this->deleteFile($zipHelper->getZipFilePath($this->rootFolder)); $this->deleteFile($zipHelper->getZipFilePath());
} }
} }

View File

@ -5,6 +5,7 @@ namespace Box\Spout\Writer\ODS;
use Box\Spout\Common\Type; use Box\Spout\Common\Type;
use Box\Spout\Reader\Wrapper\XMLReader; use Box\Spout\Reader\Wrapper\XMLReader;
use Box\Spout\TestUsingResource; use Box\Spout\TestUsingResource;
use Box\Spout\Writer\Common\Helper\ZipHelper;
use Box\Spout\Writer\WriterFactory; use Box\Spout\Writer\WriterFactory;
/** /**
@ -368,6 +369,30 @@ class WriterTest extends \PHPUnit_Framework_TestCase
$this->assertValueWasWrittenToSheet($fileName, 1, 'a dream'); $this->assertValueWasWrittenToSheet($fileName, 1, 'a dream');
} }
/**
* @return void
*/
public function testGeneratedFileShouldHaveTheCorrectMimeType()
{
// Only PHP7+ gives the correct mime type since it requires adding
// uncompressed files to the final archive (which support was added in PHP7)
if (!ZipHelper::canChooseCompressionMethod()) {
$this->markTestSkipped(
'The PHP version used does not support setting the compression method of archived files,
resulting in the mime type to be detected incorrectly.'
);
}
$fileName = 'test_mime_type.ods';
$resourcePath = $this->getGeneratedResourcePath($fileName);
$dataRow = ['foo'];
$this->writeToODSFile([$dataRow], $fileName);
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$this->assertEquals('application/vnd.oasis.opendocument.spreadsheet', $finfo->file($resourcePath));
}
/** /**
* @param array $allRows * @param array $allRows
* @param string $fileName * @param string $fileName