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