android pdf框架-14,mupdf重排
前面的文章主要在应用端.本文主要是针对文本重排,从mupdf的导出文本到应用端对这个文本再重排.尽量保持原文的格式方面作些说明.
文本重排,对于扫描版,目前不考虑,因为图片的ocr准确度不好,而且要ocr,当前的主流机型一页消耗时间太长.所以只考虑非扫描版,可以用mupdf直接导出文本的.
文本重排有两个阶段
一个是mupdf的导出.一个是针对导出的再次重排.
pdfium也有导出文本,我看了现有的库提供的接口就是直接导出文本,没有任何样式.图片也忽略.
mupdf如果是导出文本,有几类可以选择的.
导出文本
libmupdf/source/fitz/stext-output.c这是导出的核心类
enum {FZ_FORMAT_TEXT,FZ_FORMAT_HTML,FZ_FORMAT_XHTML,FZ_FORMAT_STEXT_XML,FZ_FORMAT_STEXT_JSON,
};
它支持这些导出格式,text与json是差不多的,样式就没有了.
剩下的是一类,有样式的.
我的阅读器,目前用的是text的导出,但作了修改,增加了image的部分.所以重排的时候,元素都展现了,但样式就没办法恢复.
导出text后,再对text作一些行合并,现在的规则,合并上大概50%成功率,不算高.
具体是:libmupdf/platform/java/jni/page.c
这个类,添加一个导出的方法
JNIEXPORT jbyteArray JNICALL
FUN(Page_textAsText)(JNIEnv *env, jobject self, jstring joptions)
{fz_context *ctx = get_context(env);fz_page *page = from_Page(env, self);fz_stext_page *text = NULL;fz_device *dev = NULL;fz_matrix ctm;jbyteArray arr = NULL;fz_buffer *buf = NULL;fz_output *out = NULL;unsigned char *data;size_t len;const char *options= NULL;fz_stext_options opts;if (!ctx || !page) return NULL;if (joptions){options = (*env)->GetStringUTFChars(env, joptions, NULL);if (!options) return NULL;}fz_try(ctx){fz_parse_stext_options(ctx, &opts, options);}fz_catch(ctx){}fz_var(text);fz_var(dev);fz_var(buf);fz_var(out);fz_try(ctx){ctm = fz_identity;text = fz_new_stext_page(ctx, fz_bound_page(ctx, page));dev = fz_new_stext_device(ctx, text, &opts);fz_run_page(ctx, page, dev, ctm, NULL);fz_close_device(ctx, dev);buf = fz_new_buffer(ctx, 256);out = fz_new_output_with_buffer(ctx, buf);fz_print_stext_page_as_text(ctx, out, text);fz_close_output(ctx, out);len = fz_buffer_storage(ctx, buf, &data);arr = (*env)->NewByteArray(env, (jsize)len);if ((*env)->ExceptionCheck(env))fz_throw_java(ctx, env);if (!arr)fz_throw(ctx, FZ_ERROR_GENERIC, "cannot create byte array");(*env)->SetByteArrayRegion(env, arr, 0, (jsize)len, (jbyte *)data);if ((*env)->ExceptionCheck(env))fz_throw_java(ctx, env);}fz_always(ctx){if (options)(*env)->ReleaseStringUTFChars(env, joptions, options);fz_drop_output(ctx, out);fz_drop_buffer(ctx, buf);fz_drop_device(ctx, dev);fz_drop_stext_page(ctx, text);}fz_catch(ctx)jni_rethrow(env, ctx);return arr;
}
从Page_textAsHtml复制来的,去除了html头尾.
fz_parse_stext_options 这个参数比较重要.它决定了导出的时候有没有带图片等.
内容导出是fz_print_stext_page_as_text,它加到上面的stext-output.c.最后是do_as_text这个导出文本,
在它的switch里面加上图片的导出
switch (block->type){case FZ_STEXT_BLOCK_IMAGE:fz_print_stext_image_as_html(ctx, out, block);break;case FZ_STEXT_BLOCK_TEXT:for (line = block->u.t.first_line; line; line = line->next){int break_line = 1;for (ch = line->first_char; ch; ch = ch->next){if (ch->next == NULL && (line->flags & FZ_STEXT_LINE_FLAGS_JOINED) != 0){break_line = 0;continue;}n = fz_runetochar(utf, ch->c);for (i = 0; i < n; i++)fz_write_byte(ctx, out, utf[i]);}if (break_line)fz_write_string(ctx, out, "\n");}fz_write_string(ctx, out, "\n");break;case FZ_STEXT_BLOCK_STRUCT:if (block->u.s.down != NULL)do_as_text(ctx, out, block->u.s.down->first_block);break;}
fz_print_stext_image_as_html这些功能都是现有的.我把最后一句修改了fz_write_string(ctx, out, "\"></p>\n");这样可以<p><img /></p>这样的标签,直接得到图片,是一个base64的,可以解析它.
作了这些修改后,一个带图片的文本就出来了,但是样式没有了.
合并行
pdf的页面渲染时,与重排后的显示,它的宽可能不一样,所以要合并行,这时要转到android显示上的处理了
定义一个TxtParser来解析文本,合并行.解析图片等操作.
定义数据来存储这两类
data class ReflowBean(var data: String?,var type: Int = TYPE_STRING,var page: String? = null
) {override fun toString(): String {return "ReflowBean(page=$page, data=$data)"}companion object {@JvmFieldpublic val TYPE_STRING = 0;@JvmFieldpublic val TYPE_IMAGE = 1;}
}
一个page产生的所有行,先去除空格,形成一个行的列表.然后针对每一行作出处理.
fun parseAsList(content: String, pageIndex: Int): List<ReflowBean> {//Logcat.d("parse:==>" + content)val sb = StringBuilder()val list = ArrayList<String>()var aChar: Charval rs = content.replace(SINGLE_WORD_FIX_REGEX, "")for (i in 0 until rs.length) {aChar = rs[i]if (aChar == '\n') {list.add(sb.toString())sb.setLength(0)} else {sb.append(aChar)}}//Logcat.d("result=>>" + result)return parseList(list, pageIndex)}
这部分纯体力活,判断是不是图片,图片的话单独处理.有点像解析xml.
private fun parseList(lists: List<String>, pageIndex: Int): List<ReflowBean> {val sb = StringBuilder()var isImage = falseval reflowBeans = ArrayList<ReflowBean>()var reflowBean: ReflowBean? = nullvar maxNumberCharOfLine = 20var hasImage = falsefor (s in lists) { //图片第一行会有很多字符if (s.startsWith(IMAGE_START_MARK)) {hasImage = true}if (s.length > maxNumberCharOfLine && !hasImage) {maxNumberCharOfLine = s.length}}var lastLine: Line? = nullfor (s in lists) {val ss = s.trim()if (!TextUtils.isEmpty(ss)) {//if (Logcat.loggable) {// Logcat.longLog("text", ss)//}if (ss.startsWith(IMAGE_START_MARK)) {isImage = truesb.setLength(0)reflowBean = ReflowBean(null, ReflowBean.TYPE_STRING, pageIndex.toString())reflowBean.type = ReflowBean.TYPE_IMAGEreflowBeans.add(reflowBean)}if (!isImage) {if (null == reflowBean) {reflowBean =ReflowBean(null, ReflowBean.TYPE_STRING, pageIndex.toString())reflowBeans.add(reflowBean)}lastLine = parseLine(ss, sb, pageIndex, lastLine, maxNumberCharOfLine - 5)reflowBean.data = sb.toString()} else {sb.append(ss)}if (ss.endsWith(IMAGE_END_MARK)) {isImage = falsereflowBean?.data = sb.toString()reflowBean = nullsb.setLength(0)}}}return reflowBeans}
关键在于如何处理一行的数据
/*** 重排的数据是按行获取的,只有纯文本,要把行合并起来.合并需要区分是否这一行就是结束.* 如果这行是开始标志* 则判断上一行是否有结束.没有则添加结束标志.* 追加本行* 如果这行有结束标志* 上行没有结束符* 行字数小于标准字数* 加结束符* 追加本行内容,加结束符* 如果这行没有结束标志* 上行有结束符* 追加本行内容* 上行没有结束符* 上行小于标准字数* 本行字数小于标准字数* 上行添加结束符* 追加本行内容,加结束符* 本行字数大于标准字数* 上行添加结束符* 追加本行内容* 上行大于标准字数* 本行字数小于标准字数* 追加本行内容,加结束符* 本行字数大于标准字数* 追加本行内容* @param ss source* @param sb parsed string* @param pageIndex* @param lastBreak wethere last line has a break char.*/private fun parseLine(ss: String,sb: StringBuilder,pageIndex: Int,lastLine: Line?,maxNumberCharOfLine: Int): Line {val line = StringBuilder()val thisLine = Line(ss.length < maxNumberCharOfLine)//1.处理结尾字符val end = ss.substring(ss.length - 1)//2.判断尾部的字符是否是结束符.通常是以标点结束的.或者是程序相关的字符结尾.val isEnd = if (END_MARK.contains(end) || PROGRAM_MARK.contains(end)) {Logcat.d("step2.line.end.break:$ss")true} else {false}//3.判断是否是新一行开始var lineLength = ss.lengthif (lineLength > 6) {lineLength = 6}val start = ss.substring(0, lineLength)var isStartLine = START_MARK.matcher(start).find()//Logcat.d("find:$find")if (!isStartLine) {if (ss.startsWith("“|\"|'")) {isStartLine = true}}if (!isStartLine) {if (START_MARK2.matcher(start).find()) {isStartLine = true}}if (isStartLine) {Logcat.d("step3.line break,length:${ss.length}")//如果是开始行,上行如果没有结束符,则添加上.lastLine?.run {if (!this.isEnd) {line.append(LINE_END)}}line.append(ss)if (isEnd) {line.append(LINE_END)}thisLine.isEnd = isEndthisLine.text = line.toString()sb.append(line)if (Logcat.loggable) {Logcat.d("count:${maxNumberCharOfLine} :$line")}return thisLine}//4.如果这行有结束标志if (isEnd) {lastLine?.run {//上行没有结束符,行字数小于标准字数,加结束符if (!this.isEnd && lastLine.isNotALine) {line.append(LINE_END)}}line.append(ss)line.append(LINE_END)thisLine.isEnd = truethisLine.text = line.toString()sb.append(line)if (Logcat.loggable) {Logcat.d("count1:${maxNumberCharOfLine} :$line")}return thisLine} else {//5.如果这行没有结束标志val lastLineIsEnd = (lastLine == null || lastLine.isEnd)//上行有结束符if (lastLineIsEnd) {line.append(ss)thisLine.isEnd = false} else { //上行没有结束符if (lastLine.isNotALine) { //上行小于标准字数if (ss.length < maxNumberCharOfLine) {//本行字数小于标准字数line.append(LINE_END)line.append(ss)line.append(LINE_END)thisLine.isEnd = true} else { //本行字数大于标准字数line.append(LINE_END)line.append(ss)}} else { //上行大于标准字数if (ss.length < maxNumberCharOfLine) {//本行字数小于标准字数//追加本行内容,加结束符line.append(ss)line.append(LINE_END)thisLine.isEnd = true} else { //本行字数大于标准字数line.append(ss)}}}}if (isLetterDigitOrChinese(end)) {Logcat.d("isLetterDigitOrChinese:$end")line.append(LINE_END)}thisLine.text = line.toString()sb.append(line)if (Logcat.loggable) {Logcat.d("count2:${maxNumberCharOfLine} :$line")}return thisLine}
注释上,已经写清楚了我的判断规则.这个规则目前来说,有点简单了.
针对一个普通文本,没有样式,我没有找到更好的办法.如果是有样式的html,效果会好一些.
其中一些变量
/*** 段落的开始字符可能是以下的:* 第1章,第四章.* 总结,小结,●,■,(2),(3)* //|var|val|let|这是程序的注释.需要换行,或者是程序的开头.*/internal val START_MARK =Pattern.compile("(第\\w*[^章]章)|总结|小结|○|●|■|—|//|var|val|let|fun|public|private|static|abstract|protected|import|export|pack|overri|open|class|void|for|while")internal val START_MARK2 = Pattern.compile("\\d+\\.")/*** 段落的结束字符可能是以下.*/internal const val END_MARK = ".!?.!?。!?::」?” ——"/*** 如果遇到的是代码,通常是以这些结尾*/internal const val PROGRAM_MARK = ";,]>){}"/*** 解析pdf得到的文本,取出其中的图片*/internal const val IMAGE_START_MARK = "<p><img"/*** 图片结束,jni中的特定结束符.*/internal const val IMAGE_END_MARK = "</p>"
这些规则主要是我拿一些书的示例来作的.针对中文.
这种方式的重排效果一般了,但好处就是有图片,样式经过调整后,还勉强可以看.
进一步优化
如果想要更好的效果,导出的时候应该是html,然后针对html再进行重排.这个目前在做,已经实现,只是效果还没达到预期,比纯文本肯定是好不少了.
目前mupdf的导出标签少,这是优点,那么在修改导出html是可控的因素就少了,然后针对html再合并.在webview上显示效果还行,在textview上效果不好.因为处理标签是不一样的.
优化后再写一篇关于html的合并,重排.已经接近原来的文档70%的水平,比纯文本提升20%左右吧.
如果有人做过类似的排版,有好的排版引擎,欢迎介绍给我.