#1 写在前面
- 开始写这篇文章时,标题怎么定困扰我良久,缘于不晓得如何给接下来要做的事定个简单明了的标题:在📱终端只能
纯文本
交互的前提下,优雅展示 markdown 文档中的图片。 - 这也许比问题本身还要棘手😄。
#2 背景说明
公司内网有一套基于 markdown
的文档系统,方便同事查阅资料,现希望能够在移动端进行浏览。
目前我们已在集团移动办公 APP 发布有 H5 小程序
,实现了互联网与内网的数据通信,但存在以下限制:
- 请求方式为
POST
- 后端返回内容限定为纯文本
- 每次发起请求终端都有
loading
弹窗 - 无法加载互联网资源
#3 思路阐述
**方案一:将图片编码进 markdown 文本 **
识别出 markdown 内的图片,转换为 BASE64 编码并替换原文本,终端解析后渲染。本文采用此方案✅。
方案二:延迟加载图片
终端渲染后,监听页面滚动,按需加载图片(传递 url 或图片编号,后端返回 BASE64 编码)。此方案可通过自定义指令实现,前后端均需要代码改造。
#3.1 处理流程
- 用户请求指定 ID 的 MARKDOWN 资源
- 从数据库读取原始文本,调用 MarkdownFunc.embedImages 方法
- 若该 ID 的缓存文件存在,则直接使用,跳转到⑥
- 用正则表达式匹配全部图片标签,对符合后缀规范的本地文件,进行以下操作
a. 原始图片宽度超出阈值,则先缩放
b. 转换为 WEBP 格式(节流😄)
c. 进一步转换为 BASE64 编码
d. 替换到原标签文本 - 将处理完成的文本写入缓存文件
- 返回内容到客户端
同时,当文档被修改后,监听事件,删除对应的缓存文件。
#3.2 代码实现
@Configuration
@ConfigurationProperties(prefix = "page.markdown")
class MarkdownConfig {var maxWidth = 900 //图片宽度超出此值则进行压缩var quality = 0.1F //转换为 webp 时质量阈值var resizeQuality = 0.8f //裁剪图片的质量阈值var exts = listOf("jpg","jpeg","bmp","png")var dir = "markdown"
}@Component
class MarkdownFunc(private val fileStore: FileStore,private val config: MarkdownConfig) {@Value("\${server.servlet.context-path}")private val contextPath = ""private val logger = LoggerFactory.getLogger(javaClass)/*** 转换为 Base64 编码*/private fun base64(bytes:ByteArray) = ".encodeToString(bytes)})"private fun txtFile(id: Long) = fileStore.buildPathWithoutDate("${id}.txt", config.dir)/**** @param id 文档唯一编号* @param text markdown 源文本*/fun embedImages(id:Long, text:String):String = txtFile(id).let { file->if(file.exists()) return@let Files.readString(file)Regex("!\\[.*?\\]\\((.*?)\\)").replace(text) { match->val fileUrl = match.groupValues.last().let {if(it.startsWith(contextPath))it.replaceFirst(contextPath, "")elseit}//暂不支持互联网资源if(fileUrl.startsWith("http")) return@replace match.valueval imgPath = Paths.get(".", fileUrl)val ext = imgPath.extension.lowercase()logger.info("${imgPath.toAbsolutePath() } ${imgPath.isRegularFile()}")if(imgPath.exists() && imgPath.isRegularFile()){if(config.exts.contains(ext)){var img = ImageIO.read(imgPath.toFile()).let {if(it.width > config.maxWidth){if(logger.isDebugEnabled) logger.debug("图片 $imgPath 宽度超出阈值 ${config.maxWidth} 即将裁剪...")//对图片进行缩放,如需水印可以调用 watermark 方法Thumbnails.of(it).width(config.maxWidth).outputQuality(config.resizeQuality).asBufferedImage()}elseit}val out = ByteArrayOutputStream()val mout = MemoryCacheImageOutputStream(out)ImageIO.getImageWritersByMIMEType("image/webp").next().let { writer->writer.output = moutwriter.write(null,IIOImage(img, null, null),WebPWriteParam(writer.locale).also {it.compressionMode = ImageWriteParam.MODE_EXPLICITit.compressionType = it.compressionTypes[WebPWriteParam.LOSSY_COMPRESSION]it.compressionQuality = config.quality})if(logger.isDebugEnabled) logger.debug("图片 $imgPath 转 webp 完成...")}mout.flush()base64(out.toByteArray())}//对于 webp 格式不作缩放处理直接编码else if(ext == "webp"){base64(Files.readAllBytes(imgPath))}else{if(logger.isDebugEnabled) logger.debug("图片 $imgPath 不是支持的格式...")match.value}}else {logger.error("图片 $imgPath 不存在或不是一个有效文件...")match.value}}.also {file.parent.also { p->if(!p.exists())Files.createDirectories(p)}Files.writeString(file, it, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)logger.info("缓存 $file 写入成功(SIZE = ${file.fileSize()} B)")}}@Async@EventListener(PageContentUpdateEvent::class)fun onPageUpdate(event: PageContentUpdateEvent) {event.page.also {if(it.template == Page.MARKDOWN){logger.info("检测到 #${it.id} 的内容变更,即将删除其缓存文件(若存在)...")txtFile(it.id).deleteIfExists()}}}
}