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:
parent
44d72d8245
commit
ed0e8f79cc
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user