1 | // Jomic - a viewer for comic book archives. |
2 | // Copyright (C) 2004-2011 Thomas Aglassinger |
3 | // |
4 | // This program is free software: you can redistribute it and/or modify |
5 | // it under the terms of the GNU General Public License as published by |
6 | // the Free Software Foundation, either version 3 of the License, or |
7 | // (at your option) any later version. |
8 | // |
9 | // This program is distributed in the hope that it will be useful, |
10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of |
11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
12 | // GNU General Public License for more details. |
13 | // |
14 | // You should have received a copy of the GNU General Public License |
15 | // along with this program. If not, see <http://www.gnu.org/licenses/>. |
16 | package net.sf.jomic.comic; |
17 | |
18 | import java.awt.Color; |
19 | import java.awt.Dimension; |
20 | import java.awt.image.RenderedImage; |
21 | import java.io.File; |
22 | import java.io.IOException; |
23 | import java.util.Arrays; |
24 | import java.util.Comparator; |
25 | import java.util.Iterator; |
26 | import java.util.List; |
27 | import java.util.Map; |
28 | import java.util.NoSuchElementException; |
29 | |
30 | import net.sf.jomic.common.PropertyConstants; |
31 | import net.sf.jomic.tools.ArchiveCache; |
32 | import net.sf.jomic.tools.FileArchive; |
33 | import net.sf.jomic.tools.FileTools; |
34 | import net.sf.jomic.tools.ImageCache; |
35 | import net.sf.jomic.tools.ImageInCacheListener; |
36 | import net.sf.jomic.tools.ImageTools; |
37 | import net.sf.jomic.tools.LocaleTools; |
38 | import net.sf.jomic.tools.MutexLock; |
39 | import net.sf.jomic.tools.NaturalCaseInsensitiveOrderComparator; |
40 | import net.sf.jomic.tools.StandardConstants; |
41 | import net.sf.jomic.tools.StringTools; |
42 | |
43 | import org.apache.commons.logging.Log; |
44 | import org.apache.commons.logging.LogFactory; |
45 | import org.apache.pdfbox.pdmodel.PDDocument; |
46 | import org.apache.pdfbox.pdmodel.PDPage; |
47 | import org.apache.pdfbox.pdmodel.PDResources; |
48 | import org.apache.pdfbox.pdmodel.graphics.xobject.PDXObjectImage; |
49 | |
50 | /** |
51 | * Cache for comic related extracted archives, images, thumbnails. |
52 | * |
53 | * @author Thomas Aglassinger |
54 | */ |
55 | public final class ComicCache implements StandardConstants |
56 | { |
57 | public static final int MAX_THUMB_HEIGHT = 128; |
58 | public static final int MAX_THUMB_WIDTH = 160; |
59 | private static final long DEFAULT_MINIMUM_APPLICATION_MEMORY = 128 * MEGA_BYTE; |
60 | private static final long DEFAULT_MINIMUM_IMAGE_CACHE = MEGA_BYTE; |
61 | private static final long DEFAULT_MINIMUM_THUMB_CACHE = MEGA_BYTE; |
62 | private static final long DEFAULT_MINIMUM_TITLE_THUMB_CACHE = MEGA_BYTE; |
63 | private static final double IMAGE_CACHE_WEIGHT = 0.80; |
64 | private static final double THUMB_CACHE_WEIGHT = 0.15; |
65 | private static final double TITLE_THUMB_CACHE_WEIGHT = 0.05; |
66 | private static final double TOTAL_WEIGHT = IMAGE_CACHE_WEIGHT + THUMB_CACHE_WEIGHT + TITLE_THUMB_CACHE_WEIGHT; |
67 | |
68 | private static ComicCache instance; |
69 | private static Log logger; |
70 | private long applicationMemory; |
71 | private ArchiveCache archiveCache; |
72 | private File cacheDir; |
73 | private FileTools fileTools; |
74 | private ImageCache imageCache; |
75 | private long imageCacheMemory; |
76 | private ImageTools imageTools; |
77 | private LocaleTools localeTools; |
78 | private long maxMemory; |
79 | private boolean setUpCalled; |
80 | private StringTools stringTools; |
81 | private ThumbImageCache thumbCache; |
82 | private long thumbCacheMemory; |
83 | private int thumbHeight; |
84 | private int thumbWidth; |
85 | private TitleImageCache titleCache; |
86 | private long titleCacheMemory; |
87 | |
88 | private ComicCache() { |
89 | logger = LogFactory.getLog(ComicCache.class); |
90 | fileTools = FileTools.instance(); |
91 | imageTools = ImageTools.instance(); |
92 | localeTools = LocaleTools.instance(); |
93 | stringTools = StringTools.instance(); |
94 | thumbWidth = MAX_THUMB_WIDTH; |
95 | thumbHeight = MAX_THUMB_HEIGHT; |
96 | } |
97 | |
98 | /** |
99 | * Setup the cache. This must be called before accessing it. |
100 | * |
101 | * @throws IOException |
102 | */ |
103 | public void setUp(File newCacheDir) |
104 | throws IOException { |
105 | setUp(newCacheDir, PropertyConstants.DEFAULT_ARCHIVE_CACHE_SIZE_IN_MB * MEGA_BYTE); |
106 | } |
107 | |
108 | /** |
109 | * Setup the cache. This must be called before accessing it. |
110 | */ |
111 | public void setUp(File newCacheDir, long newMaxArchiveCacheSize) |
112 | throws IOException { |
113 | assert newCacheDir != null; |
114 | assert newMaxArchiveCacheSize > 0; |
115 | |
116 | if (logger.isInfoEnabled()) { |
117 | logger.info("setup comic cache in " + stringTools.sourced(newCacheDir) |
118 | + " with size " + newMaxArchiveCacheSize); |
119 | } |
120 | cacheDir = newCacheDir; |
121 | fileTools.mkdirs(cacheDir); |
122 | |
123 | File archiveCacheDir = new File(cacheDir, "archives"); |
124 | |
125 | archiveCache = new ArchiveCache(archiveCacheDir, newMaxArchiveCacheSize); |
126 | archiveCache.attemptToReadEntries(); |
127 | |
128 | maxMemory = Runtime.getRuntime().maxMemory(); |
129 | |
130 | long minApplicationMemory = DEFAULT_MINIMUM_APPLICATION_MEMORY; |
131 | long cachableMemory = maxMemory - minApplicationMemory; |
132 | |
133 | imageCacheMemory = (long) Math.max( |
134 | cachableMemory * IMAGE_CACHE_WEIGHT / TOTAL_WEIGHT, |
135 | DEFAULT_MINIMUM_IMAGE_CACHE); |
136 | thumbCacheMemory = (long) Math.max( |
137 | cachableMemory * THUMB_CACHE_WEIGHT / TOTAL_WEIGHT, |
138 | DEFAULT_MINIMUM_THUMB_CACHE); |
139 | titleCacheMemory = (long) Math.max( |
140 | cachableMemory * TITLE_THUMB_CACHE_WEIGHT / TOTAL_WEIGHT, |
141 | DEFAULT_MINIMUM_TITLE_THUMB_CACHE); |
142 | applicationMemory = maxMemory - imageCacheMemory - thumbCacheMemory - titleCacheMemory; |
143 | |
144 | if (logger.isInfoEnabled()) { |
145 | logger.info("cache size in MB: maximum memory=" + localeTools.asByteText(maxMemory) |
146 | + ", image cache=" + localeTools.asByteText(imageCacheMemory) |
147 | + ", title cache=" + localeTools.asByteText(titleCacheMemory) |
148 | + ", thumb cache=" + localeTools.asByteText(thumbCacheMemory)); |
149 | } |
150 | imageCache = new ImageCache("images", imageCacheMemory); |
151 | thumbCache = new ThumbImageCache("thumbs", thumbCacheMemory); |
152 | titleCache = new TitleImageCache("titles", new File(cacheDir, "titles"), titleCacheMemory); |
153 | |
154 | setUpCalled = true; |
155 | } |
156 | |
157 | /** |
158 | * Get thumbnail for title image of <code>comicFile</code>. If there is no such thumbnail in |
159 | * the cache, compute a new one and put it in the cache. |
160 | */ |
161 | public RenderedImage geTitleImage(File comicFile) { |
162 | assertSetUpCalled(); |
163 | assert comicFile != null; |
164 | RenderedImage result; |
165 | |
166 | try { |
167 | result = titleCache.get(comicFile); |
168 | } catch (IOException error) { |
169 | result = titleCache.createBrokenImage(); |
170 | } |
171 | |
172 | assert result != null; |
173 | return result; |
174 | } |
175 | |
176 | /** |
177 | * Approximate number of bytes available for application and unavailable for caches. |
178 | */ |
179 | public long getApplicationMemory() { |
180 | assertSetUpCalled(); |
181 | return applicationMemory; |
182 | } |
183 | |
184 | public ArchiveCache getArchiveCache() { |
185 | assertSetUpCalled(); |
186 | return archiveCache; |
187 | } |
188 | |
189 | /** |
190 | * Total memory available to application and caches. |
191 | */ |
192 | public long getAvailableMemory() { |
193 | assertSetUpCalled(); |
194 | return maxMemory; |
195 | } |
196 | |
197 | public File getCacheDir() { |
198 | assertSetUpCalled(); |
199 | return cacheDir; |
200 | } |
201 | |
202 | public RenderedImage getImage(File imageFile) |
203 | throws IOException { |
204 | assertSetUpCalled(); |
205 | return imageCache.get(imageFile); |
206 | } |
207 | |
208 | /** |
209 | * @return Returns the imageCache. |
210 | */ |
211 | public ImageCache getImageCache() { |
212 | assertSetUpCalled(); |
213 | return imageCache; |
214 | } |
215 | |
216 | /** |
217 | * Approximate number of bytes available for image cache. |
218 | */ |
219 | public long getImageCacheMemory() { |
220 | assertSetUpCalled(); |
221 | return imageCacheMemory; |
222 | } |
223 | |
224 | /** |
225 | * @return Returns the thumbCache. |
226 | */ |
227 | public ThumbImageCache getThumbCache() { |
228 | assertSetUpCalled(); |
229 | return thumbCache; |
230 | } |
231 | |
232 | /** |
233 | * Approximate number of bytes available for thumbnail cache. |
234 | */ |
235 | public long getThumbCacheMemory() { |
236 | assertSetUpCalled(); |
237 | return thumbCacheMemory; |
238 | } |
239 | |
240 | public int getThumbHeight() { |
241 | assertSetUpCalled(); |
242 | return thumbHeight; |
243 | } |
244 | |
245 | public int getThumbWidth() { |
246 | assertSetUpCalled(); |
247 | return thumbWidth; |
248 | } |
249 | |
250 | /** |
251 | * Get thumbnail for <code>imageFile</code>. If there is no such thumbnail in the cache, |
252 | * compute a new one and put it in the cache. |
253 | */ |
254 | public RenderedImage getThumbnail(File imageFile) { |
255 | assertSetUpCalled(); |
256 | assert imageFile != null; |
257 | RenderedImage result; |
258 | |
259 | try { |
260 | result = thumbCache.get(imageFile); |
261 | } catch (IOException error) { |
262 | result = thumbCache.createBrokenImage(); |
263 | } |
264 | |
265 | assert result != null; |
266 | return result; |
267 | } |
268 | |
269 | /** |
270 | * Get thumbnail for <code>imageFile</code>. If there is no such thumbnail in the cache, return |
271 | * a placeholder image immediately, and notify <code>listener</code> when the actual thumbnail |
272 | * is ready. |
273 | */ |
274 | public RenderedImage getThumbnail(File imageFile, ImageInCacheListener listener) { |
275 | assertSetUpCalled(); |
276 | assert imageFile != null; |
277 | assert listener != null; |
278 | RenderedImage result; |
279 | |
280 | try { |
281 | result = thumbCache.get(imageFile, listener); |
282 | } catch (IOException error) { |
283 | result = thumbCache.createBrokenImage(); |
284 | } |
285 | |
286 | assert result != null; |
287 | return result; |
288 | } |
289 | |
290 | public TitleImageCache getTitleCache() { |
291 | assertSetUpCalled(); |
292 | return titleCache; |
293 | } |
294 | |
295 | /** |
296 | * Approximate number of bytes available for title thumbnail cache. |
297 | */ |
298 | public long getTitleCacheMemory() { |
299 | assertSetUpCalled(); |
300 | return titleCacheMemory; |
301 | } |
302 | |
303 | /** |
304 | * Get thumbnail for title image for <code>comicFile</code>. If there is no such thumbnail in |
305 | * the cache, return a placeholder image immediately, and notify <code>listener</code> when the |
306 | * actual thumbnail is ready. |
307 | */ |
308 | public RenderedImage getTitleImage(File comicFile, ImageInCacheListener listener) { |
309 | assertSetUpCalled(); |
310 | assert comicFile != null; |
311 | assert listener != null; |
312 | RenderedImage result; |
313 | |
314 | try { |
315 | result = titleCache.get(comicFile, listener); |
316 | } catch (IOException error) { |
317 | result = titleCache.createBrokenImage(); |
318 | } |
319 | |
320 | assert result != null; |
321 | return result; |
322 | } |
323 | |
324 | public static synchronized ComicCache instance() { |
325 | if (instance == null) { |
326 | instance = new ComicCache(); |
327 | } |
328 | return instance; |
329 | } |
330 | |
331 | public void dispose() { |
332 | if (imageCache != null) { |
333 | logger.info("clear image cache"); |
334 | imageCache.clear(); |
335 | } |
336 | if (setUpCalled) { |
337 | try { |
338 | getArchiveCache().writeEntries(); |
339 | } catch (IOException error) { |
340 | logger.warn("cannot store archive cache map, ignoring error", error); |
341 | } |
342 | } |
343 | } |
344 | |
345 | private void assertSetUpCalled() { |
346 | assert setUpCalled : "setUp() must be called first"; |
347 | } |
348 | |
349 | /** |
350 | * ImageCache for comic title pages. |
351 | * |
352 | * @author Thomas Aglassinger |
353 | */ |
354 | public class TitleImageCache extends ImageCache |
355 | { |
356 | private File lastObtainedComicFile; |
357 | private Dimension lastObtainedTitleDimension; |
358 | private RenderedImage lastObtainedTitleImage; |
359 | private Comparator naturalOrderComparator; |
360 | private MutexLock obtainImageLock; |
361 | private File titleCacheDir; |
362 | |
363 | public TitleImageCache(String newName, File newTitleCacheDir, long newMaxMemorySize) { |
364 | super(newName, newMaxMemorySize); |
365 | assert newTitleCacheDir != null; |
366 | |
367 | titleCacheDir = newTitleCacheDir; |
368 | naturalOrderComparator = new NaturalCaseInsensitiveOrderComparator(); |
369 | obtainImageLock = new MutexLock("obtainImage"); |
370 | } |
371 | |
372 | private RenderedImage getCbrOrCbzTitleImage(FileArchive comicArchive) |
373 | throws IOException { |
374 | RenderedImage result; |
375 | |
376 | String[] names = comicArchive.list(); |
377 | String titleImageName = null; |
378 | |
379 | Arrays.sort(names, naturalOrderComparator); |
380 | |
381 | for (int nameIndex = 0; (nameIndex < names.length) |
382 | && (titleImageName == null); nameIndex += 1) { |
383 | String possibleImageName = names[nameIndex]; |
384 | |
385 | if (imageTools.isImageFile(possibleImageName)) { |
386 | titleImageName = possibleImageName; |
387 | } |
388 | } |
389 | if (titleImageName == null) { |
390 | throw new NoSuchElementException( |
391 | "archive must contain at least one image"); |
392 | } |
393 | comicArchive.extract(titleCacheDir, |
394 | new String[]{titleImageName}); |
395 | |
396 | File extractedTitleImageFile = new File(titleCacheDir, |
397 | titleImageName); |
398 | |
399 | result = imageTools.readImage(extractedTitleImageFile); |
400 | fileTools.deleteOrWarn(extractedTitleImageFile, logger); |
401 | return result; |
402 | } |
403 | |
404 | private RenderedImage getPdfTitleImage(File comicFile) |
405 | throws IOException { |
406 | RenderedImage result = null; |
407 | PDDocument pdf = PDDocument.load(comicFile); |
408 | |
409 | try { |
410 | List pages = pdf.getDocumentCatalog().getAllPages(); |
411 | Iterator pageRider = pages.iterator(); |
412 | |
413 | while (pageRider.hasNext() && (result == null)) { |
414 | PDPage page = (PDPage) pageRider.next(); |
415 | PDResources resources = page.getResources(); |
416 | Map images = resources.getImages(); |
417 | |
418 | if (images != null) { |
419 | Iterator imageRider = images.values().iterator(); |
420 | |
421 | if (imageRider.hasNext()) { |
422 | PDXObjectImage image = (PDXObjectImage) imageRider.next(); |
423 | |
424 | result = image.getRGBImage(); |
425 | } |
426 | } |
427 | } |
428 | } finally { |
429 | pdf.close(); |
430 | } |
431 | return result; |
432 | } |
433 | |
434 | /** |
435 | * Extract title image from <code>comicFile</code> and return a thumbnail version of it. |
436 | */ |
437 | private RenderedImage getTitleImage(File comicFile) { |
438 | RenderedImage result; |
439 | |
440 | try { |
441 | FileArchive comicArchive = new FileArchive(comicFile); |
442 | |
443 | if (comicArchive.getFileType() == FileTools.FORMAT_PDF) { |
444 | result = getPdfTitleImage(comicFile); |
445 | } else { |
446 | result = getCbrOrCbzTitleImage(comicArchive); |
447 | } |
448 | result = imageTools.getSqueezed(result, getThumbWidth(), |
449 | getThumbHeight(), ImageTools.SCALE_FIT); |
450 | // HACK: Prevent OutOfMemoryError by avoiding RenderedImage, |
451 | // which seems to keep some evil references in the background. |
452 | result = imageTools.getAsBufferedImage(result); |
453 | } catch (Exception error) { |
454 | logger.warn("cannot get title image, using default image instead for: " + comicFile, |
455 | error); |
456 | result = imageTools.createBrokenImage(lastObtainedTitleDimension.width, |
457 | lastObtainedTitleDimension.height, Color.WHITE, Color.RED); |
458 | } |
459 | return result; |
460 | } |
461 | |
462 | protected RenderedImage obtainImage(File comicFile) |
463 | throws IOException { |
464 | if (!comicFile.equals(lastObtainedComicFile)) { |
465 | synchronized (obtainImageLock) { |
466 | lastObtainedTitleImage = getTitleImage(comicFile); |
467 | lastObtainedComicFile = comicFile; |
468 | lastObtainedTitleDimension = new Dimension( |
469 | lastObtainedTitleImage.getWidth(), lastObtainedTitleImage.getHeight()); |
470 | } |
471 | } |
472 | return lastObtainedTitleImage; |
473 | } |
474 | |
475 | protected Dimension obtainImageDimension(File comicFile) |
476 | throws IOException { |
477 | Dimension result; |
478 | |
479 | synchronized (obtainImageLock) { |
480 | if (lastObtainedTitleDimension == null) { |
481 | lastObtainedTitleDimension = new Dimension(getThumbWidth() / 2, getThumbHeight()); |
482 | } |
483 | result = lastObtainedTitleDimension; |
484 | } |
485 | return result; |
486 | } |
487 | } |
488 | |
489 | /** |
490 | * Cache for thumbnails for comic images. The thumbnail image is derived by scaling the |
491 | * original comic image to a defined thumbnail size. |
492 | * |
493 | * @author Thomas Aglassinger |
494 | */ |
495 | class ThumbImageCache extends ImageCache |
496 | { |
497 | ThumbImageCache(String newName, long newMaxMemorySize) { |
498 | super(newName, newMaxMemorySize); |
499 | } |
500 | |
501 | protected Dimension getDefaultImageDimension() { |
502 | return new Dimension(getThumbWidth() / 2, getThumbHeight()); |
503 | } |
504 | |
505 | protected RenderedImage obtainImage(File imageFile) |
506 | throws IOException { |
507 | RenderedImage thumbImage; |
508 | RenderedImage originalImage = imageCache.get(imageFile); |
509 | |
510 | thumbImage = imageTools.getSqueezed(originalImage, getThumbWidth(), |
511 | getThumbHeight(), ImageTools.SCALE_FIT); |
512 | thumbImage = imageTools.getAsBufferedImage(thumbImage); |
513 | return thumbImage; |
514 | } |
515 | |
516 | protected Dimension obtainImageDimension(File imageFile) |
517 | throws IOException { |
518 | Dimension result; |
519 | Dimension sourceDimension = super.obtainImageDimension(imageFile); |
520 | |
521 | if (sourceDimension == null) { |
522 | sourceDimension = super.getDefaultImageDimension(); |
523 | } |
524 | |
525 | assert sourceDimension != null; |
526 | |
527 | result = imageTools.getSqueezedDimension(getThumbWidth(), getThumbHeight(), |
528 | sourceDimension.width, sourceDimension.height, ImageTools.SCALE_FIT); |
529 | return result; |
530 | } |
531 | } |
532 | } |