To participate you must create an account on apostrophenow.org. If you have already done so, click Login.

root/plugins/apostrophePlugin/branches/features/cropping/lib/toolkit/aImageConverter.class.php @ 1502

Revision 1502, 19.1 KB (checked in by tboutell, 3 years ago)

1. The media repository backend now supports cropping parameters in image URLs. That is, you can request:

/uploads/media_items/myslug.width.height.cropleft.cropTop.cropWidth.cropHeight.c.jpg

And the specified cropping rectangle will be applied to the image before scaling to width/height.

2. gd now shrinks one axis rather than expanding one axis when asked to crop-and-scale to a different aspect ratio. Fixes #410

Line 
1<?php
2
3/*
4 *
5 * Efficient image conversions using netpbm or (if netpbm is not available) gd.
6 * For more information see the README file.
7 *
8 */ 
9
10class aImageConverter 
11{
12  // Produces images suitable for intentional cropping by CSS.
13  // Either the width or the height will match the request; the other
14  // will EXCEED the request. Looks nicer than letterboxing in cases
15  // where keeping the entire picture is not essential.
16
17  static public function scaleToNarrowerAxis($fileIn, $fileOut, $width, $height, $quality = 75)
18  {
19    $width = ceil($width);
20    $height = ceil($height);
21    $quality = ceil($quality);
22    list($iwidth, $iheight) = getimagesize($fileIn); 
23    if (!$iwidth) {
24      return false;
25    }
26    $iratio = $iwidth / $iheight;
27    $ratio = $width / $height;
28    if ($iratio > $ratio) {
29      $width = false;
30    } else {
31      $height = false;
32    }
33    return self::scaleToFit($fileIn, $fileOut, $width, $height, $quality);
34  }
35
36  static public function scaleToFit($fileIn, $fileOut, $width, $height, $quality = 75)
37  {
38    if ($width === false) {
39      $scaleParameters = array('ysize' => $height + 0);
40    } elseif ($height === false) {
41      $scaleParameters = array('xsize' => $width + 0);
42    } else {
43      $scaleParameters = array('xysize' => array($width + 0, $height + 0));
44    }
45    $result = self::scaleBody($fileIn, $fileOut, $scaleParameters, array(), $quality);
46    return $result;
47  }
48
49  static public function scaleByFactor($fileIn, $fileOut, $factor, 
50    $quality = 75)
51  {
52    $quality = ceil($quality);
53    $scaleParameters = array('scale' => $factor + 0); 
54    return self::scaleBody($fileIn, $fileOut, $scaleParameters, array(), $quality);
55  }
56
57  // $width and $height are the dimensions of the final rendered image. $quality is the JPEG quality setting (where needed).
58  // The $crop parameters, when not null (all four must be null or not null), are used to crop the original before scaling/distorting
59  // to the specified width and height and are always in the original image's coordinates.
60 
61  // If cropping coordinates are not specified, the largest possible portion of the center of the original image is scaled to fit into the
62  // destination image without distortion
63 
64  static public function cropOriginal($fileIn, $fileOut, $width, $height, $quality = 75, $cropLeft = null, $cropTop = null, $cropWidth = null,  $cropHeight = null)
65  {
66    // Allow skipping of parameters
67    if (is_null($quality))
68    {
69      $quality = 75;
70    }
71    $width = ceil($width);
72    $height = ceil($height);
73    $quality = ceil($quality);
74    list($iwidth, $iheight) = getimagesize($fileIn); 
75    if (!$iwidth) 
76    {
77      return false;
78    }
79    $iratio = $iwidth / $iheight;
80    $ratio = $width / $height;
81
82     // Spike's contribution: arbitrary cropping
83     if (!is_null($cropWidth) && !is_null($cropHeight) && !is_null($cropLeft) && !is_null($cropTop))
84     {
85       $cropTop = ceil($cropTop + 0);
86       $cropLeft = ceil($cropLeft + 0);
87       $cropWidth = ceil($cropWidth + 0);
88       $cropHeight = ceil($cropHeight + 0);
89       
90       $scale = array('xysize' => array($width + 0, $height + 0));
91       $crop = array('left' => $cropLeft, 'top' => $cropTop, 'width' => $cropWidth, 'height' => $cropHeight);
92       return self::scaleBody($fileIn, $fileOut, $scale, $crop, $quality);
93     }
94
95    $scale = array('xysize' => array($width + 0, $height + 0));
96    if ($iratio < $ratio)
97    {
98      $cropHeight = floor($iwidth * ($height / $width));
99      $cropTop = floor(($iheight - $cropHeight) / 2);
100      $cropLeft = 0;
101      $cropWidth = $iwidth;
102    }
103    else
104    {
105      $cropWidth = floor($iheight * $ratio);
106      $cropLeft = floor(($iwidth - $cropWidth) / 2);
107      $cropTop = 0;
108      $cropHeight = $iheight;
109    }
110    $scale = array('xysize' => array($width + 0, $height + 0));
111    $crop = array('left' => $cropLeft, 'top' => $cropTop, 'width' => $cropWidth, 'height' => $cropHeight);
112    return self::scaleBody($fileIn, $fileOut, $scale, $crop, $quality);
113  }
114
115  // Change the format without cropping or scaling
116  static public function convertFormat($fileIn, $fileOut, $quality = 75)
117  {
118    $quality = ceil($quality);
119    return self::scaleBody($fileIn, $fileOut, false, false, $quality);
120  }
121
122  static private function scaleBody($fileIn, $fileOut, $scaleParameters = array(), $cropParameters = array(), $quality = 75) 
123  {   
124    if (sfConfig::get('app_aimageconverter_netpbm', true))
125    {
126      // Auto fallback to gd, but only if it's not a small image gd can handle better (1.4). This means we get
127      // full alpha channel for manageably-sized PNGs and good performance for huge PNGs
128      $info = getimagesize($fileIn);
129      $mapTypes = array(IMAGETYPE_GIF => IMG_GIF, IMAGETYPE_PNG => IMG_PNG, IMAGETYPE_JPEG => IMG_JPG);
130      // If we got valid image info, the image size is less than 1024x768, gd is enabled, and gd supports
131      // the image type... *then* we skip to gd.
132      if (($info !== false) && (($info[0] <= 1024) && ($info[1] <= 768)) && function_exists('imagetypes') && isset($mapTypes[$info[2]]) && (imagetypes() & $mapTypes[$info[2]]))
133      {
134        return self::scaleGd($fileIn, $fileOut, $scaleParameters, $cropParameters, $quality);
135      }
136      $result = self::scaleNetpbm($fileIn, $fileOut, $scaleParameters, $cropParameters, $quality);
137      if (!$result)
138      {
139        sfContext::getInstance()->getLogger()->info("netpbm failed, not available? Trying gd");       
140        return self::scaleGd($fileIn, $fileOut, $scaleParameters, $cropParameters, $quality);
141      }
142    }
143    else
144    {
145      return self::scaleGd($fileIn, $fileOut, $scaleParameters, $cropParameters, $quality);
146    }
147  }
148 
149  static private function scaleNetpbm($fileIn, $fileOut, $scaleParameters = array(), $cropParameters = array(), $quality = 75)
150  {
151    $outputFilters = array(
152      "jpg" => "pnmtojpeg --quality %d",
153      "jpeg" => "pnmtojpeg --quality %d",
154      "ppm" => "cat",
155      "pbm" => "cat",
156      "pgm" => "cat",
157      "tiff" => "pnmtotiff",
158      "png" => "pnmtopng",
159      "gif" => "ppmquant 256 | ppmtogif",
160      "bmp" => "ppmtobmp"
161    );
162    if (preg_match("/\.(\w+)$/", $fileOut, $matches)) {
163      $extension = $matches[1];
164      $extension = strtolower($extension);
165      if (!isset($outputFilters[$extension])) {
166        return false;
167      }
168      $filter = sprintf($outputFilters[$extension], $quality);
169    } else {
170      return false;
171    }
172    $path = sfConfig::get("app_aimageconverter_path", "");
173    if (strlen($path)) {
174      if (!preg_match("/\/$/", $path)) {
175        $path .= "/";
176      }
177    }
178       
179    // AUGH: some versions of anytopnm don't have
180    // the brains to look at the file signature. We need
181    // to be compatible with this brain damage, so pick
182    // the right filter based on the results of getimagesize()
183    // and punt to anytopnm only if we can't figure it out.
184   
185    // While we're at it: detect PDF by magic number too,
186    // not by extension, that's tacky
187
188    $input = 'anytopnm';
189   
190    $in = fopen($fileIn, 'r');
191    $bytes = fread($in, 4);
192    if ($bytes === '%PDF')
193    {
194      $input = 'gs -sDEVICE=ppm -sOutputFile=- ' .
195        ' -dNOPAUSE -dFirstPage=1 -dLastPage=1 -r100 -q -';
196    }
197    fclose($in);
198   
199    $info = getimagesize($fileIn);
200    if ($info !== false)
201    {
202      $type = $info[2];
203      if ($type === IMAGETYPE_GIF)
204      {
205        $input = 'giftopnm';
206      } 
207      elseif ($type === IMAGETYPE_PNG)
208      {
209        $input = 'pngtopnm';
210      }
211      elseif ($type === IMAGETYPE_JPEG)
212      {
213        $input = 'jpegtopnm';
214      }
215    }
216   
217 
218    $scaleString = '';
219    $extraInputFilters = '';
220    foreach ($scaleParameters as $key => $values)
221    {
222      $scaleString .= " -$key ";
223      if (is_array($values))
224      {
225        foreach ($values as $value)
226        {
227          $value = ceil($value);
228          $scaleString .= " $value";
229        }
230      }
231      else
232      {
233        $values = ceil($values);
234        $scaleString .= " $values";
235      }
236    }
237    if (count($cropParameters))
238    {
239      $extraInputFilters = 'pnmcut ';
240      foreach ($cropParameters as $ckey => $cvalue)
241      {
242        $cvalue = ceil($cvalue);
243        $extraInputFilters .= " -$ckey $cvalue";
244      }
245    }
246   
247    $cmd = "(PATH=$path:\$PATH; export PATH; $input < " . escapeshellarg($fileIn) . " " . ($extraInputFilters ? "| $extraInputFilters" : "") . " " . ($scaleParameters ? "| pnmscale $scaleString " : "") . "| $filter " .
248      "> " . escapeshellarg($fileOut) . " " .
249      ") 2> /dev/null";
250    // sfContext::getInstance()->getLogger()->info("$cmd");
251    system($cmd, $result);
252    if ($result != 0) 
253    {
254      return false;
255    }
256    return true;
257  }
258 
259  static private function scaleGd($fileIn, $fileOut, $scaleParameters = array(), $cropParameters = array(), $quality = 75)
260  {
261    // gd version for those who can't install netpbm, poor buggers
262    // "handles" PDF by rendering a blank white image. We already superimpose a PDF icon,
263    // so this should work well
264   
265    // (if you can install ghostview, you can install netpbm too, so there's no middle case)
266   
267    if (preg_match('/\.pdf$/i', $fileIn))
268    {
269      $in = self::createTrueColorAlpha(100, 100);
270      imagefilledrectangle($in, 0, 0, 100, 100, imagecolorallocate($in, 255, 255, 255));
271    } 
272    else
273    {
274      $in = self::imagecreatefromany($fileIn);
275    }
276    $top = 0;
277    $left = 0;
278    $width = imagesx($in);
279    $height = imagesy($in);
280    if (count($cropParameters))
281    {
282      if (isset($cropParameters['top']))
283      {
284        $top = $cropParameters['top'];
285      }
286      if (isset($cropParameters['left']))
287      {
288        $left = $cropParameters['left'];
289      }
290      if (isset($cropParameters['width']))
291      {
292        $width = $cropParameters['width'];
293      }
294      if (isset($cropParameters['height']))
295      {
296        $height = $cropParameters['height'];
297      }
298      $cropped = self::createTrueColorAlpha($width, $height);
299      imagealphablending($cropped, false);
300      imagesavealpha($cropped, true);
301      imagecopy($cropped, $in, 0, 0, $left, $top, $width, $height);
302      imagedestroy($in);
303      $in = null;
304    }
305    else
306    {
307      // No cropping, so don't waste time and memory
308      $cropped = $in;
309      $in = null;
310    }
311 
312    if (count($scaleParameters))
313    {
314      $width = imagesx($cropped);
315      $height = imagesy($cropped);
316      $swidth = $width;
317      $sheight = $height;
318      if (isset($scaleParameters['xsize']))
319      {
320        $height = $scaleParameters['xsize'] * imagesy($cropped) / imagesx($cropped);
321        $width = $scaleParameters['xsize'];
322        $out = self::createTrueColorAlpha($width, $height);
323        imagecopyresampled($out, $cropped, 0, 0, 0, 0, $width, $height, imagesx($cropped), imagesy($cropped));
324        imagedestroy($cropped);
325        $cropped = null;
326      }
327      elseif (isset($scaleParameters['ysize']))
328      {
329        $width = $scaleParameters['ysize'] * imagesx($cropped) / imagesy($cropped);
330        $height = $scaleParameters['ysize'];
331        $out = self::createTrueColorAlpha($width, $height);
332        imagecopyresampled($out, $cropped, 0, 0, 0, 0, $width, $height, imagesx($cropped), imagesy($cropped));
333        imagedestroy($cropped);
334        $cropped = null;
335      }
336      elseif (isset($scaleParameters['scale']))
337      {
338        $width = imagesx($cropped) * $scaleParameters['scale'];
339        $height = imagesy($cropped)* $scaleParameters['scale'];
340        $out = self::createTrueColorAlpha($width, $height);
341        imagecopyresampled($out, $cropped, 0, 0, 0, 0, $width, $height, imagesx($cropped), imagesy($cropped));
342        imagedestroy($cropped);
343        $cropped = null;
344      }
345      elseif (isset($scaleParameters['xysize']))
346      {
347        $width = $scaleParameters['xysize'][0];
348        $height = $scaleParameters['xysize'][1];
349        // This was backwards until 05/31/2010, making things bigger rather than smaller if their
350        // aspect ratios differed from the original. Be consistent with netpbm which makes things
351        // smaller not bigger
352        if (($width / $height) > ($swidth / $sheight))
353        {
354          // Wider than the original. So it will be narrower than requested
355          $width = ceil($height * ($swidth / $sheight));
356        }
357        else
358        {
359          // Taller than the original. So it will be shorter than requested
360          $height = ceil($width * ($sheight / $swidth));
361        }
362        $out = self::createTrueColorAlpha($width, $height);
363        imagecopyresampled($out, $cropped, 0, 0, 0, 0, $width, $height, $swidth, $sheight);
364        imagedestroy($cropped);
365        $cropped = null;
366      }
367    }
368    else
369    {
370      // No scaling, don't waste time and memory
371      $out = $cropped;
372      $cropped = null;
373    }
374
375    if (preg_match("/\.(\w+)$/i", $fileOut, $matches))
376    {
377      $extension = $matches[1];
378      $extension = strtolower($extension);
379      if ($extension === 'gif')
380      {
381        imagegif($out, $fileOut);
382      }
383      elseif (($extension === 'jpg') || ($extension === 'jpeg'))
384      {
385        imagejpeg($out, $fileOut, $quality);
386      }
387      elseif ($extension === 'png')
388      {
389        imagepng($out, $fileOut);
390      }
391      else
392      {
393        return false;
394      }
395    }
396    imagedestroy($out);
397    $out = null;
398    return true;
399  }
400 
401  // Make sure the new image is capable of being saved with intact alpha channel;
402  // don't composite alpha channel in gd. If a designer uploads an alpha channel image
403  // they must have a reason for doing so
404  static public function createTrueColorAlpha($width, $height)
405  {
406    $im = imagecreatetruecolor($width, $height);
407    imagealphablending($im, false);
408    imagesavealpha($im, true);
409    return $im;
410  }
411 
412  // Retrieves what you really want to know about an image file, PDFs included,
413  // before making calls such as the above based on good information.
414 
415  // Returns as follows:
416 
417  // array('format' => 'file extension: gif, jpg, png or pdf', 'width' => width in pixels, 'height' => height in pixels);
418
419  // $format is the recommended file extension based on the actual file type, not the user's (possibly totally false or absent)
420  // claimed file extension.
421 
422  // If the file does not have a valid header identifying it as one of these types, false is returned.
423 
424  static public function getInfo($file)
425  {
426    $result = array();
427    $in = fopen($file, "rb");
428    $data = fread($in, 4);
429    fclose($in);
430    if ($data === '%PDF')
431    {
432      if (!aImageConverter::supportsInput('pdf'))
433      {
434        // All we can do is confirm the format and allow
435        // download of the original (which, for PDF, is
436        // usually fine)
437        return array('format' => 'pdf');
438      }
439      $result['format'] = 'pdf';
440      $path = sfConfig::get("app_aimageconverter_path", "");
441      if (strlen($path)) {
442        if (!preg_match("/\/$/", $path)) {
443          $path .= "/";
444        }
445      }
446      // Bounding box goes to stderr, not stdout! Charming
447      $cmd = "(PATH=$path:\$PATH; export PATH; gs -sDEVICE=bbox -dNOPAUSE -dFirstPage=1 -dLastPage=1 -r100 -q " . escapeshellarg($file) . " -c quit) 2>&1";
448      sfContext::getInstance()->getLogger()->info("PDFINFO: $cmd");
449      $in = popen($cmd, "r");
450      $data = stream_get_contents($in);
451      pclose($in);
452      // Actual nonfatal errors in the bbox output mean it's not safe to just
453      // read this naively with fscanf, look for the good part
454      if (preg_match("/%%BoundingBox: \d+ \d+ (\d+) (\d+)/", $data, $matches))
455      {
456        $result['width'] = $matches[1];
457        $result['height'] = $matches[2];
458      }
459      else
460      {
461        // Bad PDF
462        return false;
463      }
464      return $result;
465    }
466    else
467    {
468      $formats = array(
469        IMAGETYPE_JPEG => "jpg",
470        IMAGETYPE_PNG => "png",
471        IMAGETYPE_GIF => "gif"
472      );
473      $data = getimagesize($file);
474      if (count($data) < 3)
475      {
476        return false;
477      }
478      if (!isset($formats[$data[2]]))
479      {
480        return false;
481      }
482      $format = $formats[$data[2]];
483      $result['width'] = $data[0];
484      $result['height'] = $data[1];
485      $result['format'] = $format;
486      return $result;
487    }
488  }
489
490  // Odds and ends missing from gd
491 
492  // As commonly found on the Internets
493
494  static private function imagecreatefromany($filename) 
495  {
496    foreach (array('png', 'jpeg', 'gif', 'bmp', 'ico') as $type) 
497    {
498      $func = 'imagecreatefrom' . $type;
499      if (is_callable($func)) 
500      {
501        $image = @call_user_func($func, $filename);
502        if ($image) return $image;
503      }
504    }
505    return false;
506  }
507 
508  // Can this box handle pdf, png, jpeg (also acdepts jpg), gif, bmp, ico...
509
510  // Mainly used to check for PDF support.
511 
512  // NOTE: this call is a performance hit, especially with netpbm and ghostscript available.
513  // So we cache the result for 5 minutes. Keep that in mind if you make configuration changes, install
514  // ghostscript, etc. and don't see an immediate difference.
515
516  static public function supportsInput($extension)
517  {
518    $hint = aImageConverter::getHint("input:$extension");
519    if (!is_null($hint))
520    {
521      return $hint;
522    }
523   
524    $result = false;
525    if (sfConfig::get('app_aimageconverter_netpbm', true))
526    {
527      if (aImageConverter::supportsInputNetpbm($extension))
528      {
529        $result = true;
530      }
531    }
532    if (!$result)
533    {
534      $result = aImageConverter::supportsInputGd($extension);
535    }
536    aImageConverter::setHint("input:$extension", $result);
537    return $result;
538  }
539
540  static public function supportsInputNetpbm($extension)
541  {
542    $types = array('gif' => 'gif', 'png' => 'png', 'jpg' => 'jpeg', 'jpeg' => 'jpeg', 'bmp' => 'bmp', 'ico' => 'ico');
543    $path = sfConfig::get("app_aimageconverter_path", "");
544    if (strlen($path)) {
545      if (!preg_match("/\/$/", $path)) {
546        $path .= "/";
547      }
548    }
549    if ($extension === 'pdf')
550    {
551      $cmd = 'gs';
552    }
553    elseif (!isset($types[$extension]))
554    {
555      if (!preg_match('/^\w+$/', $extension))
556      {
557        return false;
558      }
559      $cmd = $extension . 'topnm';
560    }
561    else
562    {
563      $cmd = $types[$extension] . 'topnm';
564    }
565    $in = popen("(PATH=$path:\$PATH; export PATH; which $cmd)", "r");
566    $result = stream_get_contents($in);
567    pclose($in);
568    if (strlen($result))
569    {
570      return true;
571    }
572    return false;
573  }
574 
575  static public function supportsInputGd($extension)
576  {
577    $types = array('gif' => 'gif', 'png' => 'png', 'jpg' => 'jpeg', 'jpeg' => 'jpeg', 'bmp' => 'bmp', 'ico' => 'ico');
578    if (!isset($types[$extension]))
579    {
580      return false;
581    }
582    $f = 'imagecreatefrom' . $types[$extension];
583    return is_callable($f);
584  }
585 
586  static public function getHint($hint)
587  {
588    $cache = aImageConverter::getHintCache();
589    $key = 'apostrophe:imageconverter:' . $hint;
590    return $cache->get($key, null);
591  }
592 
593  static public function setHint($hint, $value)
594  {
595    $cache = aImageConverter::getHintCache();
596    // The lifetime should be short to avoid annoying developers who are
597    // trying to fix their configuration and test with new possibilities
598    $key = 'apostrophe:imageconverter:' . $hint;
599    $cache->set($key, $value, 300);
600  }
601  static public function getHintCache()
602  {
603    $cacheClass = sfConfig::get('app_a_hint_cache_class', 'sfFileCache');
604    $cache = new $cacheClass(sfConfig::get('app_a_hint_cache_options', array('cache_dir' => aFiles::getWritableDataFolder(array('a_hint_cache')))));
605    return $cache;
606  }
607}
Note: See TracBrowser for help on using the browser.