65.9K
CodeProject 正在变化。 阅读更多。
Home

带文本转语音功能的EPUB阅读器 for Android

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.98/5 (33投票s)

2013年5月14日

CPOL

20分钟阅读

viewsIcon

201121

downloadIcon

5093

这是一个 Android 应用程序,展示了构建 Android EPUB 文件阅读器的基础知识。

介绍 

此应用程序展示了构建 Android EPUB 文件阅读器的基础知识。

此阅读器的功能包括:

  • SD 卡上 .epub 文件的“列表视图”。
  • 书籍目录 (ToC) 的“列表视图”,能够选择 ToC 条目并跳转到该条目。
  • 查看 EPUB 的内容。
  • 能够设置书签并返回到书签。
  • 使用 Android 的文本转语音 API 朗读书籍。

本文将涵盖的内容

  • EPUB 文件格式的摘要以及阅读它的步骤。
  • 如何使用 android.sax 库解析 EPUB 文件。
  • 如何使用 Android WebClient 显示 HTML,包括:
    • 使用 Android 3.0 的 shouldInterceptRequest() 来获取要显示的 HTML。
    • Android 2.3 和 3.0 之间的 Web 客户端差异。
    • 处理 Web 客户端的缓存
    • 获取 HTML 文档的当前滚动位置
    • 重新加载文档时恢复滚动位置
    • 向 WebClient 添加“轻拂”手势处理
    • 格式化 URI
  • 如何设置 XMLFilter 链以使用 SAX 解析器处理 XML 文档。
  • 将 SAX 解析器的输出转换回 XML。
  • 使用 Parcelable 将数据打包到 intent 中,以便传递给 activity。
  • 使用 Android 的文本转语音 API

注意,由于此项目旨在展示查看 EPUB 文件的基础知识,因此我做了一些简化假设。

  • 所有文件都是 UTF-8,不支持 UTF-16。
  • 语言是英语(仅与文本转语音相关)。
  • EPUB 文件格式良好。(XML 解析中没有错误处理。)
  • 仅支持 EPUB 2.0(并且是有限的支持,例如,最小的 SVG 支持,并非所有清单属性)。

使用代码

如果您不知道如何设置 Eclipse 和 Android SDK,请点击此处获取说明。

下载项目,解压缩并导入到 Eclipse 中。最低需要 Android 2.3。

EPUB 文件格式和解析 EPUB

维基百科提供了关于 EPUB 格式的良好描述,包括官方文档的链接。对于那些不想阅读它的人,这里是 30 秒的执行摘要。EPUB 文件是一个 ZIP 文件,其中包含 HTML 文件和图像(通常在多个文件夹中)以及一些 XML 文件。HTML(实际上是 XHTML)文件和图像是书籍的内容。XML 文件是元数据,涵盖以下内容:

  • 书籍本身的信息,例如标题、作者、出版商等。
  • HTML 文件的详细信息。例如,每个文件的格式、阅读顺序、哪个文件对应于书籍的哪个章节等。
  • 目录。

由于 EPUB 文件是一个 zip 文件,我们需要做的第一件事是允许我们从 zip 文件中提取文件的函数。我们可以使用 Android 中的 java.util.zip.ZipFile 类来完成大部分工作。

public class Book {
    private ZipFile mZip;

    public Book(String fileName) {
        try {
            mZip = new ZipFile(fileName);
        } catch (IOException e) {
            Log.e(Globals.TAG, "Error opening file", e);
        }
    }

    public InputStream fetchFromZip(String fileName) {
        InputStream in = null;
        ZipEntry containerEntry = mZip.getEntry(fileName);
        if (containerEntry != null) {
            try {
                in = mZip.getInputStream(containerEntry);
            } catch (IOException e) {
                Log.e(Globals.TAG, "Error reading zip file " + fileName, e);
            }
        }
        return in;
    }
}

我们还需要解析 XML 文件。Android 有多种方法可以解析 XML 文件,IBM 的 DeveloperWorks 有一篇关于这些选项的优秀文章。我们将使用 SAX 解析器。SAX 方法的基本思想是您编写一个 ContentHandler 并将其插入到 SAX 管道中。设置 SAX 管道涉及一定量的样板代码,但以下帮助函数将为我们处理它。

void parseXmlResource(String fileName, ContentHandler handler) {
    InputStream in = fetchFromZip(fileName);
    if (in != null) {
        try {
            try {
                // obtain XML Reader
                SAXParserFactory parseFactory = SAXParserFactory.newInstance();
                XMLReader reader = parseFactory.newSAXParser().getXMLReader();
                
                // connect reader to content handler
                reader.setContentHandler(handler);

                // process XML
                InputSource source = new InputSource(in);
                source.setEncoding("UTF-8");
                reader.parse(source);
            } finally {
                in.close();
            }
        } catch (ParserConfigurationException e) {
            Log.e(Globals.TAG, "Error setting up to parse XML file ", e);
        } catch (IOException e) {
            Log.e(Globals.TAG, "Error reading XML file ", e);
        } catch (SAXException e) {
            Log.e(Globals.TAG, "Error parsing XML file ", e);
        }
    }
}

解析 EPUB 文件的第一步是读取 container.xml 文件以获取 .opf 文件的位置。根据 EPUB 规范,容器文件**始终**名为“container.xml”,并且必须位于“META-INF”文件夹中。一个典型的示例如下:

<?xml version="1.0" encoding="UTF-8" ?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
  <rootfiles>
    <rootfile full-path="content.opf" media-type="application/oebps-package+xml"/>
  </rootfiles>
</container>

要获取 .opf 文件的位置,我们遍历 <rootfile> 元素,直到找到一个媒体类型为 application/oebps-package+xml 的元素。此元素的 full-path 属性为我们提供了 .opf 文件的名称,该文件包含理解 EPUB 文件内容所需的大部分元数据。我们需要编写一个 ContentHandler 来完成此操作,并将 .opf 文件名写入 mOpfFileName,它是 Book 类的一个 String 变量。

创建 ContentHandler 的传统方法是派生一个类,该类继承自 ContentHandler,并根据需要覆盖 startElement() 和/或 endElement() 函数(以及可能的其他函数)。这种模式的困难在于,这些函数会针对每个元素调用,无论类型如何。因此,startElement() 的实现需要知道如何解析它将遇到的每种类型的元素。但是,事情甚至更复杂。它还必须跟踪它在 XML 架构中的位置,以便它可以知道它当前正在处理哪个元素。当使用非简单架构时,这经常会导致复杂的代码,因为解析单个元素的逻辑与架构跟踪逻辑混淆在一起。

稍作思考,就会发现构建解析器的更好设计会将这两个关注点分开。如下所示:

  • 描述 XML 元素之间的关系
    • 从树的根元素开始。
    • 列出我们感兴趣的根节点的预期子元素。
    • 对于每个子元素,递归列出我们感兴趣的其子元素。
  • 对于我们定义的每种元素类型,通过提供适当的 startElement() 和/或 endElement() 逻辑来指定如何解析它。

好消息是 android.sax 包允许我们以这种方式构建 ContentHandler

private static final String XML_NAMESPACE_CONTAINER = "urn:oasis:names:tc:opendocument:xmlns:container";

private String mOpfFileName;

private ContentHandler constructContainerFileParser() {
    // describe the relationship of the elements
    RootElement root = new RootElement(XML_NAMESPACE_CONTAINER,"container");
    Element rootfilesElement = root.getChild(XML_NAMESPACE_CONTAINER,"rootfiles");
    Element rootfileElement = rootfilesElement.getChild(XML_NAMESPACE_CONTAINER, "rootfile");

    // how to parse a rootFileElement 
    rootfileElement.setStartElementListener(new StartElementListener(){
        public void start(Attributes attributes) {
            String mediaType = attributes.getValue("media-type");
            if ((mediaType != null) && mediaType.equals("application/oebps-package+xml")) {
                mOpfFileName = attributes.getValue("full-path"); 
            }
        }
    });
    return root.getContentHandler();
} 

如您从上述代码中看到的,要使用 android.sax,您需要:

  • 首先创建一个 RootElement
  • 然后添加子元素以匹配您感兴趣的 XML 元素。注意,您可以省略任何您不感兴趣的元素。
  • 对于每个元素,您使用 setStartElementListenersetEndTextElementListener 和/或 setEndElementListener 来添加处理/提取 Element 所需部分的逻辑。
  • 最后,调用 getContentHandler() 将所有内容打包成一个 ContentHandler,可以将其传递给 XMLReader

将所有部分组合在一起,我们可以使用以下代码获取 .opf 文件的名称:

parseXmlResource("META-INF/container.xml", constructContainerFileParser());

.opf 文件包含一个清单、一个主干,以及一些我们不关心的其他内容,即元数据指南.opf 文件看起来像这样:

<?xml version="1.0"  encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="2.0" unique-identifier="calibre_id">
    <metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
        <-- assorted values here, which we don't care about, so I've deleted then-->
    </metadata>
    <manifest>
        <item id="id1" href="title_page.html" media-type="application/xhtml+xml"/>
        <item href="chapter1.html" id="id2.1" media-type="application/xhtml+xml"/>
        <item href="chapter2.html" id="id2.2" media-type="application/xhtml+xml"/>
        <item id="id3.1" href="stylesheet1.css" media-type="text/css"/>
        <item id="id3.2" href="stylesheet2.css" media-type="text/css"/>
        <item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
        <item href="content/resources/_cover_.jpg" id="id4.1" media-type="image/jpeg"/>
    </manifest>
    <spine toc="ncx">
        <itemref idref="id1"/>
        <itemref idref="id2.1"/>
        <itemref idref="id2.2"/>
    </spine>
</package>

清单元素包含 zip 文件中所有文件的列表。

清单项的 href 元素是 zip 文件中文件(包括路径)的名称。请注意,路径可能相对于 .opf 文件的位置。media-type 属性是文件的 mimetype。id 属性链接到主干的 idref 属性。 

书脊提供两项内容:清单中文件的阅读顺序和目录文件。阅读文件的顺序很简单。书脊中的项目按正确顺序排列,只需将书脊中的 idref 属性与清单中的 id 属性匹配即可。获取目录几乎同样容易。书脊的 toc 属性与目录文件清单条目的 id 属性匹配。鉴于此,解析 .opf 文件的内容处理器如下:

private static final String XML_NAMESPACE_PACKAGE = "http://www.idpf.org/2007/opf";

private HashMap<String, String> mManifestIndex;
private HashMap<String, String> mManifestMediaTypes;
private ArrayList<String> mSpine;
private String mTocName;

private ContentHandler constructOpfFileParser() {
    // describe the relationship of the elements
    RootElement root = new RootElement(XML_NAMESPACE_PACKAGE, "package");
    Element manifest = root.getChild(XML_NAMESPACE_PACKAGE, "manifest");
    Element manifestItem = manifest.getChild(XML_NAMESPACE_PACKAGE, "item");
    Element spine = root.getChild(XML_NAMESPACE_PACKAGE, "spine");
    Element itemref = spine.getChild(XML_NAMESPACE_PACKAGE, "itemref");
    
    // build up list of files in book from manifest    
    manifestItem.setStartElementListener(new StartElementListener(){
        public void start(Attributes attributes) {
            String href = attributes.getValue("href");
            // href may be a relative path, so need to resolve
            href = FilenameUtils.concat(FilenameUtils.getPath(mOpfFileName), href);
            mManifestIndex.put(attributes.getValue("id"), href);
            mManifestMediaTypes.put(href, attributes.getValue("media-type"))
        }
    });

    // get name of Table of Contents file from the Spine
    spine.setStartElementListener(new StartElementListener(){
        public void start(Attributes attributes) {
            String toc = attributes.getValue("toc");
            mTocName = mManifestIndex.get(toc).getHref();
        }
    });
    
    // Build "spine", the files in the zip in reading order
    itemref.setStartElementListener(new StartElementListener(){
        public void start(Attributes attributes) {
            mSpine.add(attributes.getValue("idref"));
        }
    });
    return root.getContentHandler();
}

目录文件(也称为 .ncx 文件)包含分层目录以及各种元数据。它用于为电子书用户提供目录,用户可以在其中选择目录中的项目,然后让书籍跳转到该书中的位置。.ncx 文件示例如下:

<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1" xml:lang="en">
    <head>
        <meta name="dtb:uid" content="ae60509a-b048-5f93-abd0-5333f347e4c1"/>
        <meta name="dtb:depth" content="3"/>
        <meta name="dtb:totalPageCount" content="0"/>
        <meta name="dtb:maxPageNumber" content="0"/>
    </head>
    <docTitle><text>Tax Guide</text></docTitle>
    <docAuthor><text>IRS</text></docAuthor>
    <navMap>
        <navPoint id="116f4d31-2b73-4fbd-85c4-8d437f6fccc1" playOrder="1">
            <navLabel><text>Volume 1</text></navLabel>
            <content src="volume1.html"/>
            <navPoint id="1563d3d9-33c5-472e-bcf4-587923f3137b" playOrder="2">
                <navLabel><text>Chapter 1</text></navLabel>
                <content src="volume1/chapter001.html"/>
            </navPoint>
            <navPoint id="1563d3d9-33c5-472e-bcf4-587923f3137d" playOrder="3">
                <navLabel><text>Chapter 2</text></navLabel>
                <content src="volume1/chapter002.html"/>
                <navPoint id="1563d3d9-33c5-472e-bcf4-587923f3137c" playOrder="4">
                    <navLabel><text>Section 1</text></navLabel>
                    <content src="volume1/chapter002.html#Section_1"/>
                </navPoint>
            </navPoint>
        </navPoint>
        <navPoint id="1563d3d9-33c5-472e-bcf4-587923f3137a" playOrder="5">
            <navLabel><text>Volume 2</text></navLabel>
            <content src="volume2.html"/>
        </navPoint>
    </navMap>
</ncx>

需要注意的要点是:

  • 目录信息由 <navPoint> 元素提供。
  • 每个 <navPoint> 代表一个要显示给用户的目录项。
  • <navPoint> 元素按顺序排列。
  • <navPoint> 元素可以嵌套(但通常不嵌套)。
  • 显示给用户的项目名称是 <navLabel> 元素。
  • src 属性是内容所在的位置。如果属性包含哈希“#”字符,则哈希左侧的部分是 zip 文件中包含内容的文件名。哈希右侧的部分是文件中内容开始的位置。

navPoint 可以用一个普通的 Java 对象 (POJO) 表示。

public class NavPoint {
    private String mNavLabel;
    private String mContent;
    
    public String getNavLabel() { return mNavLabel; }
    public String getContent() { return mContent; }

    public void setNavLabel(String navLabel) { mNavLabel = navLabel; }
    public void setContent(String content) { mContent = content; }
}

使用此类别,我们可以解析目录。

private ArrayList<NavPoint> mNavPoints;
private int mCurrentDepth = 0;
private int mSupportedDepth = 1;

// Used to fetch the last navPoint we're building
public NavPoint getLatestPoint() {
    return mNavPoints.get(mNavPoints.size() - 1);
}

private ContentHandler constructTocFileParser() {
    RootElement root = new RootElement(XML_NAMESPACE_TABLE_OF_CONTENTS, "ncx");
    Element navMap = root.getChild(XML_NAMESPACE_TABLE_OF_CONTENTS, "navMap");
    Element navPoint = navMap.getChild(XML_NAMESPACE_TABLE_OF_CONTENTS, "navPoint");
    AddNavPointToParser(navPoint);
    return root.getContentHandler();
}

// Build up code to parse a ToC NavPoint
private void AddNavPointToParser(final Element navPoint) {
    // describe the relationship of the elements
    Element navLabel = navPoint.getChild(XML_NAMESPACE_TABLE_OF_CONTENTS, "navLabel");
    Element text = navLabel.getChild(XML_NAMESPACE_TABLE_OF_CONTENTS, "text");
    Element content = navPoint.getChild(XML_NAMESPACE_TABLE_OF_CONTENTS, "content");
    
    navPoint.setStartElementListener(new StartElementListener(){
        public void start(Attributes attributes) {
            mNavPoints.add(new NavPoint());
            // extend parser to handle another level of nesting if required
            if (mSupportedDepth ==  ++mCurrentDepth) {
                Element child = navPoint.getChild(XML_NAMESPACE_TABLE_OF_CONTENTS,"navPoint");
                AddNavPointToParser(child);
                ++mSupportedDepth;
            }
        }
    });
    text.setEndTextElementListener(new EndTextElementListener(){
        public void end(String body) {
            getLatestPoint().setNavLabel(body);
        }
    });
    content.setStartElementListener(new StartElementListener(){
        public void start(Attributes attributes) {
            getLatestPoint().setContent(attributes.getValue("src"));
        }
    });
    navPoint.setEndElementListener(new EndElementListener(){
        public void end() {
            --mCurrentDepth;
        }
    });
}

您可能会注意到,为了处理 navPoints 的任意嵌套,在 StartElementListener 中,会跟踪嵌套级别,并在解析 XML 时根据需要向 ContentHandler 添加额外的级别。

提供目录

提供目录最简单的方法是使用 ListActivity,并将 mNavPoints 传递给它。列表视图将 NavLabel 显示为每个项目的文本。当选择一个项目时,返回相应的内容。将 mNavPoints 传递给 ListActivity 是一个有趣的问题。可能最简单的方法是使 NavPoint 类可序列化。在这种情况下,我们可以用一行代码将 mNavPoints 添加到启动 ListActivity 的 intent 中。

showTocIntent.putExtra("CHAPTERS_EXTRA", mNavPoints);

同样,从传递给 ListActivity.onCreate() 的 Bundle 中提取 mNavPoints 也只需要一行代码。

mNavPoints = getIntent().getParcelableArrayListExtra("CHAPTERS_EXTRA");

使 NavPoint 可序列化的步骤非常简单,并在 Google 的 Android 文档中给出。它们是:

  • 让类实现 android.os.Parcelable 接口。
  • 添加 Android 说明中提供的样板代码,将样板中的 MyParcelable 替换为您的类。
  • 根据您的类实现 writeToParcel() 和私有构造函数。

创建 ListActivity 以从 NavPoints 数组显示目录的其余步骤都很简单。您可以查看此项目的 ListChaptersActivity.java 文件。有关更详细的解释,我推荐这篇文章

查看内容(Android 3.0 及更高版本)

在 zip 文件中查看 HTML 文件的显而易见的方法是使用 android.webkit.WebView 类,因为它的整个目的就是显示 HTML 文件。使用它的显而易见的方法是从 EPUB 中提取 HTML 文件,然后使用 loadDataWithBaseURL(String baseUrl, String data, String mimeType, String encoding, String historyUrl) 将它们发送到 WebView。不幸的是,这不起作用,因为 HTML 文件包含指向其他文档的链接。例如:

<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
    <head>
        <title>Cover</title>
        <link href="resources/stylesheet00.css" type="text/css" charset="UTF-8" rel="stylesheet"/>
    </head>
    <body>
        <img src="resources/cover.jpg" alt="cover" style="height: 100%"/>
</html>

如您所见,这个 HTML 文件既有样式表也有图像。因此,当 WebView 尝试显示此文档时,它将尝试获取样式表和 JPEG。由于这些都埋藏在 EPUB 的 zip 文件中,WebView 无法获取它们,并且将无法显示该文档。如果我们在 Android 3.0 (即 Honeycomb) 或更高版本上运行,我们可以通过拦截 WebClient 为获取链接资源而发出的调用来解决此问题。这通过将 WebViewWebViewClient 设置为覆盖 shouldInterceptRequest() 以检索所需文件的 WebViewClient 实例来完成。例如:

public class EpubWebView extends WebView {

    public EpubWebView(Context context, AttributeSet attrs) {
        super(context, attrs);
        settings.setCacheMode(WebSettings.LOAD_NO_CACHE);        
        setWebViewClient(new WebViewClient() {
            @Override
            public WebResourceResponse shouldInterceptRequest(WebView view, String url) {
                // WebView will now call onRequest when it wants a file loaded
                // we implement this function to fetch the file from the EPUB
                return onRequest(url);
            }
        });
    }

但是,仍然有一个小问题。如果您查看 onRequest(),您会注意到请求的文件由 URL 给出。您可能还会注意到 loadDataWithBaseURL() 接受一个 URL 参数。因此,我们需要一种方法将 zip 文件中的文件名转换为 URL,然后再转换回来。我们还需要处理 zip 文件可能包含文件夹以及 HTML 文件中的链接可能是相对链接的事实。例如:

假设我们希望显示 HTML 文件“content/title.xhtml”。此文件引用图像“content/resource/cover.jpg”。那么 HTML 文件中的 <img> 元素将看起来像 '<img src="resource/cover.jpg"/>'。

将文件名转换为文件 URI 解决了这个问题。如果“file:///content/title.xhtml”作为 URL 传入,则 WebView 将使用 URL“file:///content/resource/cover.jpg”请求 jpeg。将文件名转换为 URI **不仅仅**是将“file:///”添加到文件名,因为文件名中可能出现的许多字符需要转义。幸运的是,Android 提供了 API 来完成大部分工作。

/*
 * @param url used by WebView
 * @return resourceName used by zip file
 */
private static String url2ResourceName(Uri url) {
    // we only care about the path part of the URL
    String resourceName = url.getPath();
    
    // if path has a '/' prepended, strip it
    if (resourceName.charAt(0) == '/') {
        resourceName = resourceName.substring(1);
    }
    return resourceName;
}

/*
 * @param resourceName used by zip file
 * @return URL used by WebView 
 */
public static Uri resourceName2Url(String resourceName) {
    // pack resourceName into path section of a file URI
    // need to leave '/' chars in path, so WebView is aware
    // of path to current resource, so it can can correctly resolve
    // path of any relative URLs in the current resource.
    return new Uri.Builder().scheme("file")
            .authority("")
            .appendEncodedPath(Uri.encode(resourceName, "/"))
            .build();
}

鉴于上述所有内容,在我们的 Book 类中实现 onRequest() 非常简单。

public WebResourceResponse onRequest(String url) {
    String resourceName = url2ResourceName(Uri.parse(url));
    return new WebResourceResponse(
        fetchFromZip(resourceName),
        "UTF-8",
        mManifestMediaTypes.get(resourceName)
    );
}

此时,不再需要调用 loadDataWithBaseURL()。相反,调用 loadUrl(String url)。但是,在调用 loadUrl() 之前,必须调用“clearCache(false)”。这是因为 WebView 会缓存资源请求,当 WebView 认为资源已缓存时,它不会调用 shouldInterceptRequest()。这可能是一个问题,因为许多 EPUB 书籍对样式表(stylesheet.css)和封面页图像(cover.jpg)使用相同的名称。因此,如果您查看一本 EPUB 书籍,然后查看另一本,WebView 将使用上一本书的缓存资源。这可能会让用户感到非常困惑,打开 EPUB 却看到上一本书的标题页。我发现解决这个问题的唯一方法是在调用 loadUrl() 之前显式清除缓存。将 WebView 的缓存模式设置为 LOAD_NO_CACHE 不起作用。

在 Android 2.3(及更低版本)上查看内容

正如你现在可能已经猜到的那样,Android 2.3 没有 shouldInterceptRequest()。解决方法是重写 XHTML,将所有资源内联,然后通过调用 loadDataWithBaseURL() 将 XHTML 传递给 WebView。以前面的 XHTML 示例为例:

<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
    <head>
        <title>Cover</title>
        <link href="resources/stylesheet00.css" type="text/css" charset="UTF-8" rel="stylesheet"/>
    </head>
    <body>
        <img src="resources/cover.jpg" alt="cover" style="height: 100%"/>
</html>

这有两种链接:样式表和图像。从概念上讲,样式表链接很容易移除。步骤如下:

  1. 找到样式表链接。在此示例中,只有一个:<link href="resources/stylesheet00.css" type="text/css" charset="UTF-8" rel="stylesheet"/>
  2. 在 zip 文件中找到引用的样式表文件(“resources/stylesheet00.css”)。
  3. 获取样式表的内容,例如 .bold {font-weight: bold}
  4. 用包含样式表内容的样式表元素替换 XHTML 中的链接,例如 <style>.bold {font-weight: bold}</style>

移除图像元素中的链接是一个类似但稍微困难的过程,因为我们不能直接将 jpeg 的原始字节注入到 XHTML 中。相反,我们需要将 JPEG 打包成 DataURI 并将其放入 XHTML 中。我稍后会讲到这一点。

我在 EPUB 中看到的最后一个链接是 SVG 图像。它们看起来像这样:

<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" 
         width="100%" height="100%" viewBox="0 0 300 400" preserveAspectRatio="xMidYMid meet">
    <image width="600" height="800" xlink:href="Cover.jpg"/>
</svg>

这是一个问题,因为 Android 2.3 WebView 不支持 SVG 元素。我使用的解决方案是将其转换为 <img> 元素。

数据 URI

维基百科有一篇关于 DataURI 的优秀文章。然而,这个概念是用文件的 base64 编码内容替换文件的链接。例如,替换上面示例中的图片标签:

<img src="resources/cover.jpg" alt="cover" style="height: 100%"/>

变成这样:

 

<img src="data:image/png;base64,iQU228cmaui98as..." alt="cover" style="height: 100%"/>

我这里展示的“iQU228cmaui98as...”只是 base64 序列的开头,通常有数万个字符。您会注意到 DataURI 包含了文件的 mime 类型。获取这个并不成问题,因为清单中包含了这些信息,我们将其存储在“mManifestMediaTypes”成员中。因此,我们可以使用以下函数将文件作为 DataURI 检索:

public static String fetchDataUri(String fileName)  throws IOException {
    StringBuilder sb = new StringBuilder("data:");
    sb.append(mManifestMediaTypes.get(fileName));
    sb.append(";base64,");

    int buflen = 4096;
    byte[] buffer = new byte[buflen];
    int offset = 0;
    int len = 0;

    InputStream in = fetchFromZip(fileName);
    while (len != -1) {
        len = in.read(buffer, offset, buffer.length - offset);
        if (len != -1) {
            // must process a multiple of 3 bytes, so that padding chars are not emitted
            int total = offset + len;
            offset = total % 3; 
            int bytesToProcess = total - offset;
            if (0 < bytesToProcess) {
                sb.append(Base64.encodeToString(buffer, 0, bytesToProcess, Base64.NO_WRAP));
            }
            // shuffle unused bytes to start of array
            System.arraycopy(buffer, bytesToProcess, buffer, 0, offset);
        } else if (0 < offset) {
            // flush
            sb.append(Base64.encodeToString(buffer, 0, offset, Base64.NO_WRAP));
        }
    }
    return sb.toString();
}

使用 XMLFilterImpl 实现 XML 过滤器管道

所以我们现在处于这样的阶段:我们想获取 XHTML 文件,进行一系列转换(例如,将任何链接转换为嵌入式资源),然后将生成的 XHTML 馈送到 WebView。我们该怎么做呢?

嗯,到目前为止,我们一直在使用连接到 XMLReaderContentHandler 进行处理。但是对于这种 XHTML 转换,编写一个单独的 ContentHandler 来完成这项工作将需要一个相当复杂的 ContentHandler。一个更好的解决方案是使用 XMLFilterImpl 类。这个类允许我们构建一个由更简单的处理阶段组成的 XML 处理“管道”,其中每个阶段的输出是下一个阶段的输入。也就是说,而不是这样:

file -> XMLReader -> ContentHandler -> file

我们有这个:

file -> XMLReader -> InlineStyleSheetFilter -> SvgFilter -> InlineImageFilter -> XMLWriter -> file

使用 XMLFilterImpl 非常简单。它派生自 ContentHandler,因此基本步骤是:

  • 像通常一样构建一个 ContentHandler,但要从 XMLFilterImpl 派生。
  • 在您覆盖的每个 ContentHandler 函数中,使用更改后的参数调用 XMLFilterImpl 版本的函数。

例如,这是一个将 <img> 元素转换为 DataURI 的过滤器:

public class InlineImageElementFilter extends XMLFilterImpl {

    @Override
    public void startElement(String namespaceURI, String localName, 
            String qualifiedName, Attributes attrs) throws SAXException {
        if (localName.equals("img")) {
            attrs = XmlUtil.replaceSrcAttributeValueWithDataUri(attrs);
        }
        super.startElement(namespaceURI, localName, qualifiedName, attrs);
    }
}

将过滤器连接到管道中也很容易,尽管它有些不直观。

  1. XMLReader 开始。
  2. 通过为新过滤器调用 setParent() 添加过滤器,将管道中位于其前面的现有过滤器传递给它。
  3. 创建管道后,在添加到管道的最后一个过滤器上调用 parse()

例如,要实现管道

XMLReader -> InlineStyleSheetFilter -> RemoveSvgFilter -> InlineImageFilter -> XMLWriter

代码将如下所示:

//create the filters
XMLReader     reader           = makeReader();
XMLFilterImpl stylesheetFilter = new InlineStyleSheetFilter(uri, getBook());
XMLFilterImpl svgFilter        = new RemoveSvgFilter();
XMLFilterImpl imageFilter      = new InlineImageFilter(uri, getBook());
XMLFilterImpl xmlWriter        = new XmlWriter(uri, getBook());

// build the pipeline
stylesheetFilter.setParent(reader)
svgFilter.setParent(stylesheetFilter);
imageFilter.setParent(svgFilter);
xmlWriter.setParent(imageFilter);

// run pipeline
xmlWriter.parse(source);

从 Android SAX 解析器生成 XML 输出

你们中的一些人可能知道,ContentHandler 不会生成 XML 输出。这就引出了一个问题,它是如何完成的?好吧,Android 为我们提供了 org.xmlpull.v1.XmlSerializer 类来生成 XML。不幸的是,这个类不是派生自 ContentHandler,所以它不能直接插入到 SAX 管道中。

然而,它的成员函数与 ContentHandler 的成员函数非常相似,实际上,它们几乎是一一对应的。因此,我向您介绍 XmlSerializerToXmlFilterAdapter,它接受一个 XmlSerializer 并将其包装在 XMLFilterImpl 中,以便 XmlSerializer 可以插入到管道中。去除错误处理后,它看起来像这样:

public class XmlSerializerToXmlFilterAdapter extends XMLFilterImpl {
    XmlSerializer mSerializer;
 
    public XmlSerializerToXmlFilterAdapter(XmlSerializer serializer) {
        mSerializer = serializer;
    }
    
    @Override
    public void startElement(String namespaceURI, String localName, 
            String qualifiedName, Attributes attrs) throws SAXException {
        super.startElement(namespaceURI, localName, qualifiedName, attrs);
        mSerializer.startTag(namespaceURI, localName);
        for(int i = 0; i < attrs.getLength(); ++i) {
            mSerializer.attribute(attrs.getURI(i), 
                    attrs.getLocalName(i), attrs.getValue(i));    
        }
    }

    @Override
    public void endElement(String namespaceURI, String localName, 
            String qualifiedName) throws SAXException {
        super.endElement(namespaceURI, localName, qualifiedName);
        mSerializer.endTag(namespaceURI, localName);
    }
    
    //...
    // overrides for the other ContentHandler functions.
    // startDocument(), endDocument() etc.
}

在同一应用中同时支持 2.3 和 3.0

从前面的部分可以合理地看出,Android 2.3 和 3.0 EPUB 阅读器之间实际上只有两个区别。

  • 在 3.0 中,我们创建的 WebViewClient 实现了一个 shouldInterceptRequest() 处理程序,这在 2.3 中不可用。
  • 在 3.0 中,我们可以通过调用 loadUrl() 并传入文件的 URI 来将内容加载到 WebView 中;在 2.3 中,我们必须在通过 loadDataWithBaseURL() 将实际 XML 传递给 WebView 之前获取 XML 并对其进行预处理。

要生成一个同时适用于 2.3 和 3.0 的应用程序,需要执行以下步骤:

protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    EpubWebView epubWebView = null;
    if (android.os.Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.GINGERBREAD_MR1) {
        epubWebView = new EpubWebView23(this); 
    } else {
        epubWebView = return new EpubWebView30(this); 
    }
    setContentView(epubWebView);
}
  • AndroidManifest.xml 中,我们将 android:minSdkVersion="9"android:targetSdkVersion="16"(9 = Android 2.3,16 = 3.0)设置为。
  • 我们获取派生自 WebView 的类“EpubWebView”,并创建两个虚函数:createWebClient()loadUri()
  • 我们从 EpubWebView 派生出两个类,一个用于 Android 2.3,另一个用于 Android 3.0。在每个类中,我们实现虚函数,使用适合目标 Android 版本的逻辑。
  • 我们使用“@TargetApi(Build.VERSION_CODES.HONEYCOMB)”来修饰 Android 3.0 类,这样就不会出现编译器警告,提示我们正在使用 2.3 中不可用的函数。
  • MainActivityonCreate() 方法中,我们检查 Android 版本,创建一个适当的 EpubWebView,并将其设置为活动的视图。例如:

显然,我们可以通过直接使用 Android 2.3 的代码来解决这个问题,但这会让我们失去在 Android 3.0 功能可用时使用它们的优势(例如 SVG 支持)。

我们也可以通过使用单个 EpubWebView 类来解决这个问题,并在 createWebClient()loadUri() 函数中检查 Android 版本并执行适当的逻辑。这不是一个好主意,因为您最终会使依赖于操作系统的代码(以及对操作系统版本的测试)分散在整个代码中。例如,考虑如果 2.3 和 3.0 之间有 10 个差异需要我们考虑,EpubWebView 会是什么样子。

实现书签

书签需要记录三件事。

  • 正在查看的 EPUB 文件。
  • EPUB 中选定的 HTML 文件。
  • 正在查看的 HTML 部分。这是必需的,因为 HTML 文件可能很长,通常是一个完整的章节。

不幸的是,在 Android 中,没有官方 API 可以获取屏幕上显示的文本。但是,我们可以做一些接近的事情。WebClientgetContentHeight() 函数返回一个 32 位整数,表示 HTML 的可视长度,而 getScrollY() 返回一个整数,表示当前屏幕顶部文本与 HTML 长度的对应关系。将 getScrollY() 除以 getContentHeight() 得到用户在当前 HTML 中滚动了多远的比例。我们使用比例,因为它可以让我们处理字体大小更改以及在横向和纵向之间切换屏幕方向。

要恢复书签,在加载 HTML 后,通过调用 scrollTo()WebView 滚动到所需位置。根据 Android 文档,如果您向 WebViewClient 添加 onPageFinished() 处理程序,当 HTML 文件加载完成时,将调用该处理程序。因此,显而易见的解决方案是向 WebViewClient 添加 onPageFinished() 处理程序,并让处理程序调用 scrollTo()。不幸的是,这不起作用,因为在计算可视长度之前调用了处理程序,所以 scrollTo() 调用不起作用。我们必须等到可视长度计算出来。我们可以通过使用 PictureListener() 来做到这一点。这有点复杂,因为 PictureListener() 被调用了很多次,而且它**非常**占用资源。这可能就是它被弃用的原因。解决方案是只在需要时设置 PictureListener(),即当 onPageFinished() 被调用时,并在完成后将其拆除。

因此,恢复书签的步骤是:

  • 设置标志以指示我们正在恢复书签。(因为每次加载 HTML 文件时都会调用 onPageFinished()。)
  • 调用 loadUrl()
  • 页面最初加载时,操作系统会调用 onPageFinished()。在 onPageFinished() 中,如果设置了书签标志,则注册一个 PictureListener
  • WebView 完成页面布局计算后,会调用 PictureListener,它会自行取消注册,获取 contentHeight 并调用 scrollTo() 来设置正确的位置。

处理 PictureListener 的代码很简单,唯一值得注意的是使用了 @SuppressWarnings 来阻止编译器警告。请注意,设置 onPageFinished() 处理程序与设置 shouldInterceptRequest() 处理程序非常相似,后者已经展示过。

private boolean mRestoringBookmark;
private float mScrollY;

@SuppressWarnings("deprecation")
protected void onPageFinished() {
    if (mRestoringBookmark) {
        setPictureListener(mPictureListener);
        mRestoringBookmark = false;
    }
}

@SuppressWarnings("deprecation")
private PictureListener mPictureListener = new PictureListener() {
    @Override
    public void onNewPicture(WebView view, Picture picture) {
        // stop listening 
        setPictureListener(null);
        
        scrollTo(0, (int)(getContentHeight() * mScrollY));
    }
};

实现水平滑动以在 HTML 文件之间移动

这几乎与我在漫画书阅读器文章中使用的方法相同。为了检测轻拂,我们重写 WebViewonTouchEvent(),将 MotionEvents 传递给 GestureDetector,并重写 GestureDetector.SimpleOnGestureListener.onFling() 以便在发生轻拂时调用。唯一不同的是 WebView 有自己的手势处理逻辑。为了确保它正常工作,在 onTouchEvent() 中,我们必须将 GestureDetector 未使用的任何 MotionEvents() 传递给 WebView。例如:

@Overridee
public boolean onTouchEvent(MotionEvent event) {
    return mGestureDetector.onTouchEvent(event) ||
         super.onTouchEvent(event);
}

轻拂逻辑很简单,因为我们按查看顺序(从书脊)排列了 HTML 文件,我们只需在书脊中找到当前的 HTML 文件,找到下一个(或上一个)HTML 文件,然后使用 HTML 的 URI 调用 WebViewloadUrl()

使用 Android 文本转语音 (TTS) API

在 Android 中进行文本转语音的基本步骤是:

  • 检查 TTS 是否存在。这需要启动一个 intent 并在 onActivityResult() 中检查返回结果。但 Android 4.1 及更高版本不支持此功能。不过,4.1 规范要求 TTS 必须存在,因此跳过此测试。
  • 创建 android.speech.tts.TextToSpeech 的实例,并传递一个 OnInitListener 处理程序,以便在 TextToSpeech 可用时调用。
  • 当处理程序被调用时,配置 TTS。例如,设置语言、阅读速度和 OnUtteranceCompletedListener
  • 现在可以调用 speak(),传入要朗读的文本。
  • 当 TTS 完成朗读文本时,将调用您的 OnUtteranceCompletedListener,此时,使用下一段要朗读的文本再次调用 speak()

我原本打算写更多关于 TTS 的内容。但是,这篇文章非常详细地涵盖了这些内容。我想不出还能添加什么有用的东西了。

从 XHTML 中提取文本

为了实现文本转语音,我们需要文本。因此,我们需要从 XHTML 中提取文本。

要做到这一点,你猜对了,我们使用 ContentHandler。这没什么大不了的。显示给用户的文本是元素的内部文本,所以提取文本只是收集 ContentHandlercharacters() 函数的结果。除此之外,唯一的问题是:

  • <head> 元素中的所有文本用户都不可见,因此忽略在那里找到的任何文本。
  • 在某些元素(例如 <h1>)的文本后添加空格,以防止相邻元素的文本连在一起。

有关详细信息,请查看附件源代码中的文件 XhtmlToText.java

鸣谢

我想感谢

  • Pharos Systems 的 Paul、Ross 和 Sean 对本文早期草稿的反馈和校对。
  • Baen Books 允许我使用其部分 epub 书籍的摘录作为示例。(尽管最终我自己写了。)
© . All rights reserved.