MARKDOWN 文档图片编码嵌入方案

#1 写在前面

  • 开始写这篇文章时,标题怎么定困扰我良久,缘于不晓得如何给接下来要做的事定个简单明了的标题:在终端只能纯文本交互的前提下,优雅展示 markdown 文档中的图片。
  • 这也许比问题本身还要棘手。

#2 背景说明

公司内网有一套基于 markdown 的文档系统,方便同事查阅资料,现希望能够在移动端进行浏览。

目前我们已在集团移动办公 APP 发布有 H5 小程序,实现了互联网与内网的数据通信,但存在以下限制:

  • 请求方式为 POST
  • 后端返回内容限定为纯文本
  • 每次发起请求终端都有 loading 弹窗
  • 无法加载互联网资源

#3 思路阐述

**方案一:将图片编码进 markdown 文本 **

识别出 markdown 内的图片,转换为 BASE64 编码并替换原文本,终端解析后渲染。本文采用此方案✅。

方案二:延迟加载图片

终端渲染后,监听页面滚动,按需加载图片(传递 url 或图片编号,后端返回 BASE64 编码)。此方案可通过自定义指令实现,前后端均需要代码改造。

#3.1 处理流程

  1. 用户请求指定 ID 的 MARKDOWN 资源
  2. 从数据库读取原始文本,调用 MarkdownFunc.embedImages 方法
  3. 若该 ID 的缓存文件存在,则直接使用,跳转到⑥
  4. 用正则表达式匹配全部图片标签,对符合后缀规范的本地文件,进行以下操作
    a. 原始图片宽度超出阈值,则先缩放
    b. 转换为 WEBP 格式(节流)
    c. 进一步转换为 BASE64 编码
    d. 替换到原标签文本
  5. 将处理完成的文本写入缓存文件
  6. 返回内容到客户端

同时,当文档被修改后,监听事件,删除对应的缓存文件。

#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) = "![](data:image/webp;base64,${Base64.getEncoder().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, "")
                    else
                        it
                }
                //暂不支持互联网资源
                if(fileUrl.startsWith("http"))  return@replace match.value

                val 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()
                            }
                            else
                                it
                        }

                        val out = ByteArrayOutputStream()
                        val mout = MemoryCacheImageOutputStream(out)
                        ImageIO.getImageWritersByMIMEType("image/webp").next().let { writer->
                            writer.output = mout

                            writer.write(
                                null,
                                IIOImage(img, null, null),
                                WebPWriteParam(writer.locale).also {
                                    it.compressionMode = ImageWriteParam.MODE_EXPLICIT
                                    it.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()
            }
        }
    }
}

你可能感兴趣的:(移动端开发,前端,JAVA,前端,BASE64,MARKDOWN)