• 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()
                }
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
  • 相关阅读:
    C++二分算法:找到最接近目标值的函数值
    Camunda 7.x 系列【43】事务子流程
    微信小程序云开发 | 城市信息管理
    Mongodbd的简学
    【劳动者捍卫自己的权利】
    springboot+nodejs+vue高校实验室设备管理系统
    【Java 基础篇】Java网络编程实时数据流处理
    c++_learning-并发与多线程
    报错:Gradle build failed.See the Console for details.(已解决)
    《MongoDB入门教程》第19篇 文档更新之$rename操作符
  • 原文地址:https://blog.csdn.net/ssrc0604hx/article/details/132194654