Normalize source file spacing
[bookreader.git] / BookReaderIA / datanode / BookReaderImages.php
1 <?php
2
3 /*
4 Copyright(c)2008 Internet Archive. Software license AGPL version 3.
5
6 This file is part of BookReader.
7
8 The canonical short name of an image type is the same as in the MIME type.
9 For example both .jpeg and .jpg are considered to have type "jpeg" since
10 the MIME type is "image/jpeg".
11
12     BookReader is free software: you can redistribute it and/or modify
13     it under the terms of the GNU Affero General Public License as published by
14     the Free Software Foundation, either version 3 of the License, or
15     (at your option) any later version.
16
17     BookReader is distributed in the hope that it will be useful,
18     but WITHOUT ANY WARRANTY; without even the implied warranty of
19     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
20     GNU Affero General Public License for more details.
21
22     You should have received a copy of the GNU Affero General Public License
23     along with BookReader.  If not, see <http://www.gnu.org/licenses/>.
24 */
25
26 $MIMES = array('gif' => 'image/gif',
27                'jp2' => 'image/jp2',
28                'jpg' => 'image/jpeg',
29                'jpeg' => 'image/jpeg',
30                'png' => 'image/png',
31                'tif' => 'image/tiff',
32                'tiff' => 'image/tiff');
33                
34 $EXTENSIONS = array('gif' => 'gif',
35                     'jp2' => 'jp2',
36                     'jpeg' => 'jpeg',
37                     'jpg' => 'jpeg',
38                     'png' => 'png',
39                     'tif' => 'tiff',
40                     'tiff' => 'tiff');
41                
42 $exiftool = '/petabox/sw/books/exiftool/exiftool';
43
44 // Process some of the request parameters
45 $zipPath  = $_REQUEST['zip'];
46 $file     = $_REQUEST['file'];
47 if (isset($_REQUEST['ext'])) {
48     $ext = $_REQUEST['ext'];
49 } else {
50     // Default to jpg
51     $ext = 'jpeg';
52 }
53 if (isset($_REQUEST['callback'])) {
54     // validate callback is valid JS identifier (only)
55     $callback = $_REQUEST['callback'];
56     $identifierPatt = '/^[[:alpha:]$_]([[:alnum:]$_])*$/';
57     if (! preg_match($identifierPatt, $callback)) {
58         BRfatal('Invalid callback');
59     }
60 } else {
61     $callback = null;
62 }
63
64 /*
65  * Approach:
66  * 
67  * Get info about requested image (input)
68  * Get info about requested output format
69  * Determine processing parameters
70  * Process image
71  * Return image data
72  * Clean up temporary files
73  */
74  
75 function getUnarchiveCommand($archivePath, $file)
76 {
77     $lowerPath = strtolower($archivePath);
78     if (preg_match('/\.([^\.]+)$/', $lowerPath, $matches)) {
79         $suffix = $matches[1];
80         
81         if ($suffix == 'zip') {
82             return 'unzip -p '
83                 . escapeshellarg($archivePath)
84                 . ' ' . escapeshellarg($file);
85         } else if ($suffix == 'tar') {
86             return '7z e -so '
87                 . escapeshellarg($archivePath)
88                 . ' ' . escapeshellarg($file);
89         } else {
90             BRfatal('Incompatible archive format');
91         }
92
93     } else {
94         BRfatal('Bad image stack path');
95     }
96     
97     BRfatal('Bad image stack path or archive format');
98     
99 }
100
101 /*
102  * Returns the image type associated with the file extension.
103  */
104 function imageExtensionToType($extension)
105 {
106     global $EXTENSIONS;
107     
108     if (array_key_exists($extension, $EXTENSIONS)) {
109         return $EXTENSIONS[$extension];
110     } else {
111         BRfatal('Unknown image extension');
112     }            
113 }
114
115 /*
116  * Get the image width, height and depth from a jp2 file in zip.
117  */
118 function getImageInfo($zipPath, $file)
119 {
120     global $exiftool;
121     
122     $fileExt = strtolower(pathinfo($file, PATHINFO_EXTENSION));
123     $type = imageExtensionToType($fileExt);
124      
125     // We look for all the possible tags of interest then act on the
126     // ones presumed present based on the file type
127     $tagsToGet = ' -ImageWidth -ImageHeight -FileType'        // all formats
128                  . ' -BitsPerComponent -ColorSpace'          // jp2
129                  . ' -BitDepth'                              // png
130                  . ' -BitsPerSample';                        // tiff
131                         
132     $cmd = getUnarchiveCommand($zipPath, $file)
133         . ' | '. $exiftool . ' -S -fast' . $tagsToGet . ' -';
134     exec($cmd, $output);
135     
136     $tags = Array();
137     foreach ($output as $line) {
138         $keyValue = explode(": ", $line);
139         $tags[$keyValue[0]] = $keyValue[1];
140     }
141     
142     $width = intval($tags["ImageWidth"]);
143     $height = intval($tags["ImageHeight"]);
144     $type = strtolower($tags["FileType"]);
145     
146     switch ($type) {
147         case "jp2":
148             $bits = intval($tags["BitsPerComponent"]);
149             break;
150         case "tiff":
151             $bits = intval($tags["BitsPerSample"]);
152             break;
153         case "jpeg":
154             $bits = 8;
155             break;
156         case "png":
157             $bits = intval($tags["BitDepth"]);
158             break;
159         default:
160             BRfatal("Unsupported image type");
161             break;
162     }
163    
164    
165     $retval = Array('width' => $width, 'height' => $height,
166         'bits' => $bits, 'type' => $type);
167     
168     return $retval;
169 }
170
171 /*
172  * Output JSON given the imageInfo associative array
173  */
174 function outputJSON($imageInfo, $callback)
175 {
176     header('Content-type: text/plain');
177     $jsonOutput = json_encode($imageInfo);
178     if ($callback) {
179         $jsonOutput = $callback . '(' . $jsonOutput . ');';
180     }
181     echo $jsonOutput;
182 }
183
184 // Get the image size and depth
185 $imageInfo = getImageInfo($zipPath, $file);
186
187 // Output json if requested
188 if ('json' == $ext) {
189     // $$$ we should determine the output size first based on requested scale
190     outputJSON($imageInfo, $callback);
191     exit;
192 }
193
194 // Unfortunately kakadu requires us to know a priori if the
195 // output file should be .ppm or .pgm.  By decompressing to
196 // .bmp kakadu will write a file we can consistently turn into
197 // .pnm.  Really kakadu should support .pnm as the file output
198 // extension and automatically write ppm or pgm format as
199 // appropriate.
200 $decompressToBmp = true;
201 if ($decompressToBmp) {
202   $stdoutLink = '/tmp/stdout.bmp';
203 } else {
204   $stdoutLink = '/tmp/stdout.ppm';
205 }
206
207 $fileExt = strtolower(pathinfo($file, PATHINFO_EXTENSION));
208
209 // Rotate is currently only supported for jp2 since it does not add server load
210 $allowedRotations = array("0", "90", "180", "270");
211 $rotate = $_REQUEST['rotate'];
212 if ( !in_array($rotate, $allowedRotations) ) {
213     $rotate = "0";
214 }
215
216 // Image conversion options
217 $pngOptions = '';
218 $jpegOptions = '-quality 75';
219
220 // The pbmreduce reduction factor produces an image with dimension 1/n
221 // The kakadu reduction factor produceds an image with dimension 1/(2^n)
222 // $$$ handle continuous values for scale
223 if (isset($_REQUEST['height'])) {
224     $ratio = floatval($_REQUEST['origHeight']) / floatval($_REQUEST['height']);
225     if ($ratio <= 2) {
226         $scale = 2;
227         $powReduce = 1;    
228     } else if ($ratio <= 4) {
229         $scale = 4;
230         $powReduce = 2;
231     } else {
232         //$powReduce = 3; //too blurry!
233         $scale = 2;
234         $powReduce = 1;
235     }
236
237 } else {
238     $scale = $_REQUEST['scale'];
239     if (1 >= $scale) {
240         $scale = 1;
241         $powReduce = 0;
242     } else if (2 == $scale) {
243         $powReduce = 1;
244     } else if (4 == $scale) {
245         $powReduce = 2;
246     } else if (8 == $scale) {
247         $powReduce = 3;
248     } else if (16 == $scale) {
249         $powReduce = 4;
250     } else if (32 == $scale) {
251         $powReduce = 5;
252     } else {
253         // $$$ Leaving this in as default though I'm not sure why it is...
254         $scale = 8;
255         $powReduce = 3;
256     }
257 }
258
259 // Override depending on source image format
260 // $$$ consider doing a 302 here instead, to make better use of the browser cache
261 // Limit scaling for 1-bit images.  See https://bugs.edge.launchpad.net/bookreader/+bug/486011
262 if (1 == $imageInfo['bits']) {
263     if ($scale > 1) {
264         $scale /= 2;
265         $powReduce -= 1;
266         
267         // Hard limit so there are some black pixels to use!
268         if ($scale > 4) {
269             $scale = 4;
270             $powReduce = 2;
271         }
272     }
273 }
274
275 if (!file_exists($stdoutLink)) 
276 {  
277   system('ln -s /dev/stdout ' . $stdoutLink);  
278 }
279
280
281 putenv('LD_LIBRARY_PATH=/petabox/sw/lib/kakadu');
282
283 $unzipCmd  = getUnarchiveCommand($zipPath, $file);
284
285 switch ($imageInfo['type']) {
286     case 'jp2':
287         $decompressCmd = 
288             " | /petabox/sw/bin/kdu_expand -no_seek -quiet -reduce $powReduce -rotate $rotate -i /dev/stdin -o " . $stdoutLink;
289         if ($decompressToBmp) {
290             $decompressCmd .= ' | bmptopnm ';
291         }
292         break;
293
294     case 'tiff':
295         // We need to create a temporary file for tifftopnm since it cannot
296         // work on a pipe (the file must be seekable).
297         // We use the BookReaderTiff prefix to give a hint in case things don't
298         // get cleaned up.
299         $tempFile = tempnam("/tmp", "BookReaderTiff");
300     
301         // $$$ look at bit depth when reducing
302         $decompressCmd = 
303             ' > ' . $tempFile . ' ; tifftopnm ' . $tempFile . ' 2>/dev/null' . reduceCommand($scale);
304         break;
305  
306     case 'jpeg':
307         $decompressCmd = ' | jpegtopnm ' . reduceCommand($scale);
308         break;
309
310     case 'png':
311         $decompressCmd = ' | pngtopnm ' . reduceCommand($scale);
312         break;
313         
314     default:
315         BRfatal('Unknown source file extension: ' . $fileExt);
316         break;
317 }
318        
319 // Non-integer scaling is currently disabled on the cluster
320 // if (isset($_REQUEST['height'])) {
321 //     $cmd .= " | pnmscale -height {$_REQUEST['height']} ";
322 // }
323
324 switch ($ext) {
325     case 'png':
326         $compressCmd = ' | pnmtopng ' . $pngOptions;
327         break;
328         
329     case 'jpeg':
330     case 'jpg':
331     default:
332         $compressCmd = ' | pnmtojpeg ' . $jpegOptions;
333         $ext = 'jpeg'; // for matching below
334         break;
335
336 }
337
338 if (($ext == $fileExt) && ($scale == 1) && ($rotate === "0")) {
339     // Just pass through original data if same format and size
340     $cmd = $unzipCmd;
341 } else {
342     $cmd = $unzipCmd . $decompressCmd . $compressCmd;
343 }
344
345 # print $cmd;
346
347
348 // $$$ investigate how to flush cache when this file is changed
349 header('Content-type: ' . $MIMES[$ext]);
350 header('Cache-Control: max-age=15552000');
351 passthru ($cmd); # cmd returns image data
352
353 if (isset($tempFile)) {
354     unlink($tempFile);
355 }
356
357 function BRFatal($string) {
358     echo "alert('$string');\n";
359     die(-1);
360 }
361
362 // Returns true if using a power node
363 function onPowerNode() {
364     exec("lspci | fgrep -c Realtek", $output, $return);
365     if ("0" != $output[0]) {
366         return true;
367     } else {
368         exec("egrep -q AMD /proc/cpuinfo", $output, $return);
369         if ($return == 0) {
370             return true;
371         }
372     }
373     return false;
374 }
375
376 function reduceCommand($scale) {
377     if (1 != $scale) {
378         if (onPowerNode()) {
379             return ' | pnmscale -reduce ' . $scale;
380         } else {
381             return ' | pnmscale -nomix -reduce ' . $scale;
382         }
383     } else {
384         return '';
385     }
386 }
387
388
389 ?>
390