带文本转语音功能的EPUB阅读器 for Android
这是一个 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
- 使用 Android 3.0 的
- 如何设置 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 元素。注意,您可以省略任何您不感兴趣的元素。
- 对于每个元素,您使用
setStartElementListener
、setEndTextElementListener
和/或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
为获取链接资源而发出的调用来解决此问题。这通过将 WebView
的 WebViewClient
设置为覆盖 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>
这有两种链接:样式表和图像。从概念上讲,样式表链接很容易移除。步骤如下:
- 找到样式表链接。在此示例中,只有一个:
<link href="resources/stylesheet00.css" type="text/css" charset="UTF-8" rel="stylesheet"/>
。 - 在 zip 文件中找到引用的样式表文件(“resources/stylesheet00.css”)。
- 获取样式表的内容,例如
.bold {font-weight: bold}
。 - 用包含样式表内容的样式表元素替换 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
。我们该怎么做呢?
嗯,到目前为止,我们一直在使用连接到 XMLReader
的 ContentHandler
进行处理。但是对于这种 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);
}
}
将过滤器连接到管道中也很容易,尽管它有些不直观。
- 从
XMLReader
开始。 - 通过为新过滤器调用
setParent()
添加过滤器,将管道中位于其前面的现有过滤器传递给它。 - 创建管道后,在添加到管道的最后一个过滤器上调用
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 中不可用的函数。 - 在
MainActivity
的onCreate()
方法中,我们检查 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 可以获取屏幕上显示的文本。但是,我们可以做一些接近的事情。WebClient
的 getContentHeight()
函数返回一个 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 文件之间移动
这几乎与我在漫画书阅读器文章中使用的方法相同。为了检测轻拂,我们重写 WebView
的 onTouchEvent()
,将 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 调用 WebView
的 loadUrl()
。
使用 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
。这没什么大不了的。显示给用户的文本是元素的内部文本,所以提取文本只是收集 ContentHandler
的 characters()
函数的结果。除此之外,唯一的问题是:
<head>
元素中的所有文本用户都不可见,因此忽略在那里找到的任何文本。- 在某些元素(例如
<h1>
)的文本后添加空格,以防止相邻元素的文本连在一起。
有关详细信息,请查看附件源代码中的文件 XhtmlToText.java
。
鸣谢
我想感谢
- Pharos Systems 的 Paul、Ross 和 Sean 对本文早期草稿的反馈和校对。
- Baen Books 允许我使用其部分 epub 书籍的摘录作为示例。(尽管最终我自己写了。)