All referred files are located at GitHub (https://github.com/dausi/zipgallery).
ZIP image gallery how-to
To retrieve a single image file from the server the client (browser) loads the ZIP archive with attached image file name. Having the ZIP archive available at
http://my.site/some/path/image-gallery.zip
then the image file some-image.jpg
can be retrieved from the ZIP image file utilising the URL
http://my.site/some/path/image-gallery.zip/some-image.jpg
To extract an image files from the ZIP archive, the server backend uses PHP. To enable the PHP code, an Apache-Rewrite-Regel is utilised:
RewriteEngine On
RewriteCond %{REQUEST_URI} ^(.*)/(.+.zip)/(.+)$
RewriteCond %{DOCUMENT_ROOT}%1/%2 -f
RewriteRule . /external/zipgallery/galleries.php?zip=%1/%2&file=%3 [L,QSA]
On following these rewrite rules, the URL from above is converted (rewritten) to
http://my.site/external/zipgallery/galleries.php?zip=/some/path/image-gallery.zip&file=some-image.jpg
File galleries.php
This file represents the interface to the functionality coded in PHP classes.
Lines [8 - 14] do include the PHP files utilised for the PHP classes ZipGallery
and ZipGalleryCache
.
On the first readout of the ZIP archive the table of contents including all EXIF and IPTC data is extracted. A JSON file with this data is written to a cache to accelerate access to the images. The cache is defined by parameters in function getZipGalleryCacheConfig()
[16 - 22].
[24] explodes the query string, [25] instantiates the ZipGallery (se file zip_gallery.php
), the path name to the ZIP archive is contained in query string variable zip
. Next the query string is evaluated:
- Does the query string contain the variable
info
the JSON formatted info file is returned [26 - 39]. On query variable tnw or tnh set (thumbnail width / height, [28 - 35]) each image is written base64 coded into the JSON formatted info file.
- The query string variable
file
qualifies the requested image file from the ZIP archive.
- Having a variable
thumb
a thumbnail image is returned. The variables tnh
(ThumbNailHeight) and tnw
(ThumbNailWidth) do specify the thumbnail size (standard: 50 x 50) [43 -48].
- Otherwise the requested image file is returned [51].
Only JPEG formatted image files contained in the ZIP archive are paid respect to.
<?php
/**
* galleries.php
*
* Copyright 2016, 2017 - TDSystem Beratung & Training - Thomas Dausner
*/
spl_autoload_register(function($classname) {
$classname = strtolower(trim(preg_replace('/([A-Z])/', '_$1', $classname), '_'));
require dirname(__FILE__) . DIRECTORY_SEPARATOR . $classname . '.php';
});
spl_autoload_call('ZipGallery');
spl_autoload_call('ZipGalleryCache');
function getZipGalleryCacheConfig()
{
return [
'cacheRoot' => dirname(dirname(dirname(__FILE__))) . '/application/files/zip_cache',
'cacheEntries' => 10000
];
}
parse_str($_SERVER['QUERY_STRING'], $query);
$zip = new ZipGallery($query['zip']);
if (isset($query['info']))
{
$tnSize = null;
if (isset($query['tnw']) || isset($query['tnh']))
{
$tnSize = [
'tnw' => isset($query['tnw']) ? $query['tnw'] : 50,
'tnh' => isset($query['tnh']) ? $query['tnh'] : 50
];
}
header('Content-Type: text/json'); // JSON info
header('Access-Control-Allow-Origin: *');
echo $zip->getInfo($tnSize);
}
elseif (isset($query['file']))
{
header('Content-Type: image/jpeg'); // JPG picture
if (isset($query['thumb']))
{
$tnw = isset($query['tnw']) ? $query['tnw'] : 50;
$tnh = isset($query['tnh']) ? $query['tnh'] : 50;
echo $zip->getThumb($query['file'], $tnw, $tnh);
}
else
{
echo $zip->getFromZip($query['file']);
}
}
?>
File zip_gallery.php
This file contains a class ZipGallery
extending PHP standard class ZipArchive
[9]. On creation of an instance (function __construct()
) the ZIP archive is opened [45 - 54]. On opening OK the table of contents is extracted from the ZIP archive [53 - 54] and the cache installed [55]. Cache entries names are prefixed by the ZIP archive path name [56]. To avoid deletion of any ZIP archive information files, a cache ignore pattern is defined [57].
[71 - 80] defines method getFromZip()
to read an image file from the ZIP archive. Reading ZIP archive is performed in the corresponding cache class [77].
private
method getFromCache()
[87 - 96] reads a file from cache. To check cache expiration the modification time of the ZIP archive is supplied.
The bit more voluminous method getInfo()
[100 - 181] reads a ZIP archive info file info.json
from cache [106], if available. If not present the table of contents is extracted (for
-loop) [111]. Image files of file type jpg
or jpeg
only are considered. [124] reads EXIF information from each image file including IPTC information, if present [138 - 147]. Finally [156, 157] the information is JSON formatted and written to cache.
If the ZIP archive info file info.json
is available from cache and the parameter $tnSize (thumbnail size) set the JSON data is reverted to the media information array $this->media [161 - 164]. Media information is enriched by the corresponding thumbnail images [172 - 176] and converted to JSON [177] and returned.
The final method getThumb()
[190- 239] creates a thumbnail image file or reads it from cache, if present. Name of a cache file is extended by thumbnail size (tnw x tnh
). By this any image file from a ZIP archive can populate different dimensioned thumbnail image files in cache. The thumbnail creation algorithm generates a snippet oriented at the image center according to the thumbnail dimensions [200- 235]. A thumbnail image file is created as a jpg
file and written to cache on delivery [229 - 235].
<?php
/**
* Class ZipGallery
*
* A representation of an image gallery from a ZIP archive
*
* Copyright 2016, 2017 - TDSystem Beratung & Training - Thomas Dausner
*/
class ZipGallery extends ZipArchive
{
protected $zipFilename;
protected $zipStat;
protected $cache;
protected $cacheNamePrefix;
protected $zip;
protected $entries;
protected $iptcFields = [
'2#005' => 'title',
'2#010' => 'urgency',
'2#015' => 'category',
'2#020' => 'subcategories',
'2#025' => 'subject',
'2#040' => 'specialInstructions',
'2#055' => 'cdate',
'2#080' => 'authorByline',
'2#085' => 'authorTitle',
'2#090' => 'city',
'2#095' => 'state',
'2#101' => 'country',
'2#103' => 'OTR',
'2#105' => 'headline',
'2#110' => 'source',
'2#115' => 'photoSource',
'2#116' => 'copyright',
'2#120' => 'caption',
'2#122' => 'captionWriter'
];
/**
* Opens a ZIP file and scans it for contained files.
*
* @param string $zipFilename
*/
public function __construct($zipFilename)
{
$this->entries = 0;
$this->zipFilename = $zipFilename;
$pathToZip = $_SERVER['DOCUMENT_ROOT'] . '/' . $zipFilename;
$this->zip = new ZipArchive;
if ($this->zip->open($pathToZip) == true)
{
$this->zipStat = stat($pathToZip);
$this->entries = $this->zip->numFiles;
$this->cache = new ZipGalleryCache;
$this->cacheNamePrefix = ltrim($this->zipFilename, '/') . '/';
$this->cache->setIgnorePattern('/\.json$/');
}
}
public function __destruct()
{
}
/**
* Get file identified by file name from ZIP archive.
* Returns data or FALSE.
*
* @param string filename
*/
public function getFromZip($filename)
{
$data = FALSE;
if ($this->entries > 0)
{
$data = $this->zip->getFromName($filename);
}
return $data;
}
/**
* Get file identified by file name from cache.
* Returns data or null.
*
* @param string filename
*/
private function getFromCache($filename)
{
$data = null;
if ($this->entries > 0)
{
$data = $this->cache->getEntry($this->zipStat['mtime'], $this->cacheNamePrefix . $filename);
}
return $data;
}
/**
* Get entries from ZIP archive as JSON array
*/
public function getInfo($tnSize)
{
$info = null;
if ($this->entries > 0)
{
// ZIP file is open, look for cached info entry
if (($info = $this->getFromCache('info.json')) === null)
{
// ZIP file info is not in cache, generate and set into cache
$entryNum = 0;
$finfo = new finfo(FILEINFO_NONE);
for ($i = 0; $i < $this->zip->numFiles; $i++)
{
$stat = $this->zip->statIndex($i);
$filename = $stat['name'];
if (preg_match('/jpe?g$/i', $filename) === 1)
{
// ZIP entry is relevant file
$data = $this->zip->getFromName($filename);
// init decoded IPTC fields with pseudo 'filename'
$iptcDecoded = [
'filename' => $filename
];
if (($exif = @exif_read_data('data://image/jpeg;base64,'.base64_encode($data), null, true)) !== false)
{
$size = getimagesizefromstring($data, $imgInfo);
if (isset($imgInfo['APP13']))
{
if (($iptc = iptcparse($imgInfo['APP13'])) != null)
foreach ($iptc as $key => $value)
{
$idx = isset($this->iptcFields[$key]) ? $this->iptcFields[$key] : $key;
$iptcDecoded[$idx] = $value;
}
}
}
$exifData = [];
foreach ($exif as $exKey => $exValue)
{
foreach ($exValue as $key => $value)
{
if (is_array($value) || $finfo->buffer($value) != 'data')
{
$exifData[$exKey][$key] = $value;
}
}
}
$this->media[$entryNum++] = [
'name' => $filename,
'exif' => $exifData,
'iptc' => $iptcDecoded
];
}
}
$info = json_encode($this->media, JSON_PARTIAL_OUTPUT_ON_ERROR);
$this->cache->setEntry($this->cacheNamePrefix . 'info.json', $info);
}
else
{
if ($tnSize !== null)
{
$this->media = json_decode($info, true);
}
}
if ($tnSize !== null)
{ /*
* enrich json by thumbs
*/
$tnw = $tnSize['tnw'];
$tnh = $tnSize['tnh'];
foreach($this->media as $idx => $value)
{
$filename = $this->media[$idx]['name'];
$this->media[$idx]['thumbnail'] = base64_encode($this->getThumb($filename, $tnw, $tnh));
}
$info = json_encode($this->media, JSON_PARTIAL_OUTPUT_ON_ERROR);
}
}
return $info;
}
/**
* Generate thumb from file identified by file name.
* Outputs thumbnail and returns true or false in case of error.
*
* @param string filename
* @param int new_width
* @param int new_height
*/
public function getThumb($filename, $new_width, $new_height)
{
$tnFilename = $new_width . 'x' . $new_height . '/' . $filename;
$data = $this->getFromCache($tnFilename);
if ($data === null)
{
// not in cache, create
$data = $this->getFromZip($filename);
if ($data != null)
{
$im = imagecreatefromstring($data);
list($width, $height) = getimagesizefromstring($data);
if ($new_width < 0)
{
//
// fixed height, flexible width
//
$new_width = intval($new_height * $width / $height);
}
$x = $y = 0;
if ($new_width == $new_height)
{
//
// square thumbnail
//
if ($width > $height)
{
$x = intval(($width - $height ) / 2);
$width = $height;
}
else
{
$y = intval(($height - $width ) / 2);
$height = $width;
}
}
$tnail = imagecreatetruecolor($new_width, $new_height);
imagecopyresampled($tnail, $im, 0, 0, $x, $y, $new_width, $new_height, $width, $height);
ob_start();
if (imagejpeg($tnail, null))
{
$data = ob_get_contents();
$this->cache->setEntry($this->cacheNamePrefix . $tnFilename, $data);
}
ob_end_clean();
}
}
return $data;
}
}
File zip_gallery_cache.php
Class ZipGalleryCache
coded in this file implements a file system based cache. A database cache is implemented in the solution for concrete5.
On instantiation (function __construct()
) the configuration is retrieved (function getZipCacheConfig()
, see above file galleries.php
) and the cache directory created, if not present [33 - 36].
Method setIgnorePattern()
[44 - 47] sets the cache ignore pattern used by method setEntry()
.
Method getEntry()
[55 - 75] checks the presence of a cache entry. In advance all slashes (/
) in the cache file name are replaced by number signs (#
). If the cache file entry exists the date is checked against the supplied expiration time [66]. On not expired the cache file content is returned [61]. On expired the cache file is unlinked [71]. Thus the cache file contents are always up-to-date.
Method setEntry()
[80 - 125] writes new cache entry. Again slashes (/
) in the cache file name are replaced. If the cache file name matches the cache ignore pattern (ignorePattern
) the cache file is instantly written to cache. On no match the number of cache entries is calculated [93 - 100] and compared to the maximum number of cache entries allowed [102]. If the number of cache entries exceeds the oldest cache entry is determined [106 - 112] and unlinked [114 - 121]. Eventually the cache file is written.
<?php
/**
* Class ZipGalleryCache
*
* implemantation of a simple cache.
*
* Copyright 2016, 2017 - TDSystem Beratung & Training - Thomas Dausner
*
* Configuration data for cache:
* [
* 'cacheRoot' => dirname(dirname(dirname(__FILE__))) . '/application/files/zip_cache',
* 'cacheEntries' => 10000
* ];
*
* All cache entries are kept in one folder. Cache entry file names are set up in caller.
*
* On running into $config['cacheEntries'] number of cache entries the oldest entry is discarded.
*
* Each cache entry file names consists of
* - the full path to the zip file (leading '/' character stripped)
* - attached the name of the file from the zip archive having all chars '/' replaces by '#'.
*/
class ZipGalleryCache
{
private $cacheFolder;
private $maxEntries;
private $ignorePattern = '';
public function __construct()
{
$config = getZipGalleryCacheConfig();
$this->cacheFolder = $config['cacheRoot'];
if (!is_dir($this->cacheFolder))
{
mkdir($this->cacheFolder, 0755, true);
}
$this->maxEntries = $config['cacheEntries'];
}
public function __destruct()
{
}
public function setIgnorePattern($ignorePattern)
{
$this->ignorePattern = $ignorePattern;
}
/*
* get entry from cache.
* entry found but older than 'oldest' is umlinked.
*
* @return null or content
*/
public function getEntry($oldest, $cacheName)
{
$cacheEntry = $this->cacheFolder . '/' . str_replace('/', '#', $cacheName);
$data = null;
$cStat = @stat($cacheEntry);
if (is_array($cStat))
{
// cached file exists
if ($oldest <= $cStat['mtime'])
{
// cached file is newer or same as $oldest
$data = file_get_contents($cacheEntry);
}
else
{
// cached file is older than $oldest
unlink($cacheEntry);
}
}
return $data;
}
/*
* set entry from cache
*/
public function setEntry($cacheName, $data)
{
$cacheEntry = $this->cacheFolder . '/' . str_replace('/', '#', $cacheName);
if (preg_match($this->ignorePattern, $cacheEntry) === 0)
{
$dirEntries = scandir($this->cacheFolder);
$entries = array();
if ($this->ignorePattern == '')
{
$entries = $dirEntries;
}
else
{
$idx = 0;
for ($i = 0; $i < count($dirEntries); $i++)
{
if (preg_match($this->ignorePattern, $dirEntries[$i]) === 0)
{
$entries[$idx++] = $dirEntries[$i];
}
}
}
if (count($entries) >= $this->maxEntries + 2)
{
// must unlink oldest
// create array having entries mtime => filename
$times = [];
// first $entries are '.' and '..'
for ($i = 2; $i < count($entries); $i++)
{
$times[stat($this->cacheFolder . '/' . $entries[$i])['mtime']] = $entries[$i];
}
ksort($times);
// first entry keeps oldest file
foreach($times as $mtime => $filename)
{
if ($filename != $cacheName)
{
unlink($this->cacheFolder . '/' . $filename);
break;
}
}
}
}
return file_put_contents($cacheEntry, $data) !== false;
}
}