/* * Copyright (C) 2013 Alex Kuiper * * This file is part of PageTurner * * PageTurner is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * PageTurner is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with PageTurner. If not, see <http://www.gnu.org/licenses/>.* */ package net.nightwhistler.pageturner.view.bookview; import android.text.Spannable; import android.text.SpannableString; import com.google.inject.Inject; import com.osbcp.cssparser.CSSParser; import com.osbcp.cssparser.PropertyValue; import com.osbcp.cssparser.Rule; import jedi.option.Option; import net.nightwhistler.htmlspanner.FontFamily; import net.nightwhistler.htmlspanner.HtmlSpanner; import net.nightwhistler.htmlspanner.TagNodeHandler; import net.nightwhistler.htmlspanner.css.CSSCompiler; import net.nightwhistler.htmlspanner.css.CompiledRule; import net.nightwhistler.pageturner.Configuration; import net.nightwhistler.pageturner.view.FastBitmapDrawable; import nl.siegmann.epublib.domain.Book; import nl.siegmann.epublib.domain.Resource; import nl.siegmann.epublib.epub.EpubReader; import nl.siegmann.epublib.util.IOUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.StringWriter; import java.lang.ref.SoftReference; import java.util.*; import static jedi.functional.FunctionalPrimitives.isEmpty; import static jedi.option.Options.none; import static jedi.option.Options.option; /** * Singleton storage for opened book and rendered text. * * Optimization in case of rotation of the screen. */ public class TextLoader implements LinkTagHandler.LinkCallBack { /** * We start clearing the cache if memory usage exceeds 75%. */ private static final double CACHE_CLEAR_THRESHOLD = 0.75; private String currentFile; private Book currentBook; private Map<String, Spannable> renderedText = new HashMap<>(); private Map<String, List<CompiledRule>> cssRules = new HashMap<>(); private Map<String, FastBitmapDrawable> imageCache = new HashMap<>(); private Map<String, Map<String, Integer>> anchors = new HashMap<>(); private List<AnchorHandler> anchorHandlers = new ArrayList<>(); private static final Logger LOG = LoggerFactory.getLogger("TextLoader"); private HtmlSpanner htmlSpanner; private EpubFontResolver fontResolver; private LinkTagHandler.LinkCallBack linkCallBack; @Inject public void setHtmlSpanner(HtmlSpanner spanner) { this.htmlSpanner = spanner; this.htmlSpanner.setFontResolver(fontResolver); spanner.registerHandler("a", registerAnchorHandler(new LinkTagHandler(this))); spanner.registerHandler("h1", registerAnchorHandler(spanner.getHandlerFor("h1"))); spanner.registerHandler("h2", registerAnchorHandler(spanner.getHandlerFor("h2"))); spanner.registerHandler("h3", registerAnchorHandler(spanner.getHandlerFor("h3"))); spanner.registerHandler("h4", registerAnchorHandler(spanner.getHandlerFor("h4"))); spanner.registerHandler("h5", registerAnchorHandler(spanner.getHandlerFor("h5"))); spanner.registerHandler("h6", registerAnchorHandler(spanner.getHandlerFor("h6"))); spanner.registerHandler("p", registerAnchorHandler(spanner.getHandlerFor("p"))); spanner.registerHandler("link", new CSSLinkHandler(this)); } public void setFontResolver( EpubFontResolver resolver ) { this.fontResolver = resolver; this.htmlSpanner.setFontResolver( fontResolver ); } public void registerCustomFont( String name, String href ) { LOG.debug( "Registering custom font " + name + " with href " + href ); this.fontResolver.loadEmbeddedFont(name, href); } public List<CompiledRule> getCSSRules( String href ) { if ( this.cssRules.containsKey(href) ) { return Collections.unmodifiableList(cssRules.get(href)); } List<CompiledRule> result = new ArrayList<>(); if ( currentBook == null ) { return result; } String strippedHref = href.substring( href.lastIndexOf('/') + 1); Resource res = null; for ( Resource resource: this.currentBook.getResources().getAll() ) { if ( resource.getHref().endsWith(strippedHref) ) { res = resource; break; } } if ( res == null ) { LOG.error("Could not find CSS resource " + strippedHref ); return new ArrayList<>(); } StringWriter writer = new StringWriter(); try { IOUtil.copy(res.getReader(), writer); List<Rule> rules = CSSParser.parse(writer.toString()); LOG.debug("Parsed " + rules.size() + " raw rules."); for ( Rule rule: rules ) { if ( rule.getSelectors().size() == 1 && rule.getSelectors().get(0).toString().equals("@font-face")) { handleFontLoadingRule(rule); } else { result.add(CSSCompiler.compile(rule, htmlSpanner)); } } } catch (IOException io) { LOG.error("Error while reading resource", io); return new ArrayList<>(); } catch (Exception e) { LOG.error("Error reading CSS file", e); } finally { res.close(); } cssRules.put(href, result); LOG.debug("Compiled " + result.size() + " CSS rules."); return result; } public void invalidateCachedText() { this.renderedText.clear(); } private void handleFontLoadingRule(Rule rule) { String href = null; String fontName= null; for (PropertyValue prop: rule.getPropertyValues() ) { if ( prop.getProperty().equals("font-family") ) { fontName = prop.getValue(); } if ( prop.getProperty().equals("src") ) { href = prop.getValue(); } } if ( fontName.startsWith("\"") && fontName.endsWith("\"")) { fontName = fontName.substring(1, fontName.length() -1 ); } if ( fontName.startsWith("\'") && fontName.endsWith("\'")) { fontName = fontName.substring(1, fontName.length() -1 ); } if ( href.startsWith("url(") ) { href = href.substring( 4, href.length() -1 ); } registerCustomFont(fontName, href); } private AnchorHandler registerAnchorHandler( TagNodeHandler wrapThis ) { AnchorHandler handler = new AnchorHandler(wrapThis); anchorHandlers.add(handler); return handler; } @Override public void linkClicked(String href) { if ( linkCallBack != null ) { linkCallBack.linkClicked(href); } } public void setLinkCallBack( LinkTagHandler.LinkCallBack callBack ) { this.linkCallBack = callBack; } public void registerTagNodeHandler( String tag, TagNodeHandler handler ) { this.htmlSpanner.registerHandler(tag, handler); } public boolean hasCachedBook( String fileName ) { return fileName != null && fileName.equals( currentFile ); } public Book initBook(String fileName) throws IOException { if (fileName == null) { throw new IOException("No file-name specified."); } if ( hasCachedBook( fileName ) ) { LOG.debug("Returning cached Book for fileName " + currentFile ); return currentBook; } closeCurrentBook(); this.anchors = new HashMap<>(); // read epub file EpubReader epubReader = new EpubReader(); Book newBook = epubReader.readEpubLazy(fileName, "UTF-8"); this.currentBook = newBook; this.currentFile = fileName; return newBook; } public Option<Integer> getAnchor( String href, String anchor ) { if ( this.anchors.containsKey(href) ) { Map<String, Integer> nestedMap = this.anchors.get( href ); return option(nestedMap.get(anchor)); } return none(); } public Book getCurrentBook() { return this.currentBook; } public void setFontFamily(FontFamily family) { this.fontResolver.setDefaultFont(family); } public void setSerifFontFamily(FontFamily family) { this.fontResolver.setSerifFont(family); } public void setSansSerifFontFamily(FontFamily family) { this.fontResolver.setSansSerifFont(family); } public void setStripWhiteSpace(boolean stripWhiteSpace) { this.htmlSpanner.setStripExtraWhiteSpace(stripWhiteSpace); } public void setAllowStyling(boolean allowStyling) { this.htmlSpanner.setAllowStyling(allowStyling); } public void setUseColoursFromCSS( boolean useColours ) { this.htmlSpanner.setUseColoursFromStyle(useColours); } public FastBitmapDrawable getCachedImage( String href ) { return imageCache.get( href ); } public boolean hasCachedImage( String href ) { return imageCache.containsKey(href); } public void storeImageInChache( String href, FastBitmapDrawable drawable ) { this.imageCache.put(href, drawable); } private void registerNewAnchor(String href, String anchor, int position ) { if ( ! anchors.containsKey(href)) { anchors.put(href, new HashMap<>()); } anchors.get(href).put(anchor, position); } public Option<Spannable> getCachedTextForResource( Resource resource ) { LOG.debug( "Checking for cached resource: " + resource ); return option(renderedText.get(resource.getHref())); } public Spannable getText( final Resource resource, HtmlSpanner.CancellationCallback cancellationCallback ) throws IOException { Option<Spannable> cached = getCachedTextForResource( resource ); if ( ! isEmpty(cached) ) { return cached.unsafeGet(); } for ( AnchorHandler handler: this.anchorHandlers ) { handler.setCallback((anchor, position) -> registerNewAnchor(resource.getHref(), anchor, position)); } double memoryUsage = Configuration.getMemoryUsage(); double bitmapUsage = Configuration.getBitmapMemoryUsage(); LOG.debug("Current memory usage is " + (int) (memoryUsage * 100) + "%" ); LOG.debug("Current bitmap memory usage is " + (int) (bitmapUsage * 100) + "%" ); //If memory usage gets over the threshold, try to free up memory if ( memoryUsage > CACHE_CLEAR_THRESHOLD || bitmapUsage > CACHE_CLEAR_THRESHOLD) { LOG.debug("Clearing cached resources."); clearCachedText(); closeLazyLoadedResources(); } boolean shouldClose = false; Resource res = resource; //If it's already in memory, use that. If not, create a copy //that we can safely close after using it if ( ! resource.isInitialized() ) { res = new Resource( this.currentFile, res.getSize(), res.getOriginalHref() ); shouldClose = true; } Spannable result = new SpannableString(""); try { result = htmlSpanner.fromHtml(res.getReader(), cancellationCallback); renderedText.put(res.getHref(), result); } catch (Exception e) { LOG.error("Caught exception while rendering text", e); result = new SpannableString( e.getClass().getSimpleName() + ": " + e.getMessage() ); } finally { if ( shouldClose ) { //We have the rendered version, so it's safe to close the resource resource.close(); } } return result; } private void closeLazyLoadedResources() { if ( currentBook != null ) { for ( Resource res: currentBook.getResources().getAll() ) { res.close(); } } } public void clearCachedText() { clearImageCache(); anchors.clear(); renderedText.clear(); cssRules.clear(); } public void closeCurrentBook() { if ( currentBook != null ) { for ( Resource res: currentBook.getResources().getAll() ) { res.setData(null); //Release the byte[] data. } } currentBook = null; currentFile = null; renderedText.clear(); clearImageCache(); anchors.clear(); } public void clearImageCache() { for (Map.Entry<String, FastBitmapDrawable> draw : imageCache.entrySet()) { draw.getValue().destroy(); } imageCache.clear(); } }