起因

博客换了新的主题 Bleaching,支持黑夜模式。但在黑夜模式下,原本一些流程图就不好看了,比如原本在《中控考勤机破解杂记》一文中,对触发器更新打卡时间的示意图。那张图原本是用 processon 画的,背景也置为了透明,但 1. 位图对缩放不友好;2. 颜色无法根据主题自适应;3. 制作和修改流程图繁琐。

原本是考虑用 mermaid 来做替换,但 mermaid 有个欠缺的地方,就是流程图方向难以控制绘图边界,后来经过研究,决定让博客支持 PlantUML

PlantUML 支持在线调用:仅需要将代码 encode 成对应的字符串,拼接成对应的 URL 即可访问并获取图形,即允许我们在最终渲染完成的 html 文件中采用 img 标签来显示对应的图形。当然,也可以直接生成对应图形,通过指定 base64 的方式渲染在 HTML 中。

初探

当我们在 markdown 里写 LaTeX 和 mermaid 时,我们是将其包裹在 fenced code block 中的:

```mermaid
graph LR
A --> B
```

经最终渲染后,一般都会将其内容包裹在 pre 标签和 code 标签中,并在标签上打上对应语言 class 属性:

<pre>
    <code class="language-mermaid">graph LR
A --> B</code>
</pre>

但显然,mermaid.js 和 katex.js 是针对 class 为对应语法的div层起作用的,如果将其也包裹在 pre 和 code 中,那么前端渲染将失效。

但 flexmark-java 开启了 GitLabExtension 后,是不会将语言为mermaidmath的 fenced code block 渲染在 pre 和 code 标签内的,而仅仅是将其内容作为文本包裹在 div 层中,然后打上对应的 class,使其能够被前端的 js 正确捕获并渲染:

<div class="mermaid">graph LR
A --> B
</div>

也就是说,flexmark-java 有自己的一套流程(实际上 flexmark 的任何衍生版本都是),允许扩展对特定语言类型的 fenced code block 进行自定义渲染。而为了支持 PlantUML,我们也恰巧需要对语言标记为plantuml的内容进行自定义渲染,这便对我们支持 PlantUML 提供了可能。

原理

根据 flexmark-java 的 Wiki 指南,以及对 GitLabExtension 的源码阅读,我们知道对特定 fenced code block 的渲染发生在第 6 步:HTML Rendering 里,其本质是当识别到特定语言的 Node 时,对其进行自定义拼接:

// Code pieces from GitLabNodeRenderer.java
if (this.options.renderBlockMermaid 
    // If `language` is in String[]{"mermaid"}
    && language.isIn(options.mermaidLanguages)) {
    html.line();
    // Open a `div` tag, and set the `class`
    // tag to `options.blockMermaidClass`,
    // which is "mermaid" by default.
    html.srcPosWithTrailingEOL(node.getChars()).attr(Attribute.CLASS_ATTR, this.options.blockMermaidClass).withAttr().tag("div").line().openPre();
    if (codeContentBlock) {
        context.renderChildren(node);
    } else {
        // node.getContentChars() provides the
        // inner text of the fenced block code.
        html.text(node.getContentChars().normalizeEOL());
    }
    // close this div
    html.closePre().tag("/div");

    html.lineIf(htmlOptions.htmlBlockCloseTagEol);
} else {
    context.delegateRender();
}

我们要做的就是仿照 GitLabExtension 来写一个 PlantUMLExtension,然后按照 flexmark-java 提供的拼装方式,拼装成对应的 HTML 元素即可。

private void render(FencedCodeBlock node, NodeRendererContext context, HtmlWriter html) {
    HtmlRendererOptions htmlOptions = context.getHtmlOptions();
    BasedSequence language = node.getInfoDelimitedByAny(htmlOptions.languageDelimiterSet);

    if (this.options.renderBlockPlantUML
    // I set `plantUMLLanguages` as String[]{
    //     "plantuml",
    //     "plantuml:png",
    //     "plantuml:svg"}
    // by default in order to appoint format
    && language.isIn(options.plantUMLLanguages)) {
        String format = "png";

        if (language.length() > 8) {
            @NotNull BasedSequence formatSeq = language.subSequence(9);
            format = formatSeq.toString();
        }
        String encode = deflaterCompressString(node.getContentChars().normalizeEOL());
        // Do your own rendering
        render(node, html, htmlOptions, format, encode, options.darkMode);

        if (options.bothModes) {
            render(node, html, htmlOptions, format, encode, !options.darkMode);
        }

    } else {
        context.delegateRender();
    }
}

通过调用 url 的方式生成图形

PlantUML 允许通过在线服务的形式,远程调用并生成图形:

从代码生成图形 从代码生成图形
这也是一张远程调用的图形

需要做的就是参照文档提供的 特殊的 Base64 编码,将源码转换为压缩后的编码,然后在自定义的 NodeRenderer 中拼装为 img 标签。

Deflate 压缩

Source Code 采用 Deflate 压缩成字节数组,压缩率任选。可以 Raw Deflate,也可以是带 Header 的 Deflate。

特殊的仿 URL-Safe Base64 编码

按上面提供的编码文档进行编码。

拼接 URL

如果一开始选用的压缩是 Raw Delfate,那么直接拼接即可。如果是带 Header 的 Deflate,那么要在 64 编码后的字符串前拼上“~1”,再拼接到 URL 中。

适应黑夜模式

对于 PlantUML 的在线服务,其提供了正常和黑夜两种模式的渲染,我们可以通过拼接不同 URL 来获取不同模式下的图形:

正常模式:
png: https://www.plantuml.com/plantuml/png/[encoding]
svg: https://www.plantuml.com/plantuml/svg/[encoding]
黑夜模式:
png: https://www.plantuml.com/plantuml/dpng/[encoding]
svg: https://www.plantuml.com/plantuml/dsvg/[encoding]

为了适应博客的日夜模式,只需要在渲染的时候,同时渲染两张图形,并对两种不同模式的图形的 img 标签打上不同的 class:

<!-- for bright mode -->
<img class="plantuml" src="[bright mode image]"/>
<!-- for  dark  mode -->
<img class="plantuml dark" src="[dark mode image]"/>

然后在主题上配置对应的样式:

body:not(.dark) .plantuml.dark, body.dark .plantuml:not(.dark) {
    display: none;
}

即可。具体根据不同主题来。

通过调用 api 的方式生成图形

可以参照 这篇文档,渲染出对应图形后,再编码为 data:[<mediatype>][;base64],<data>Data URLs 的形式,再进行拼装。原理类似。

适应黑夜模式

方式 1

你可以自定义 NodeRenderer 对 fenced code block 的解析规则,比如在语言后加上样式注入:

```plantuml[class="dark"]
[Your PlantUML source code here]
```

NodeRenderer 在识别到对应的 [class=*] 序列时,将其解析并添加到 Attribute 中。然后,在编辑 Markdown 时,同时编辑两份 PlantUML 代码:一份是正常的不带 class 的代码,另一份是配置了 PlantUML 黑夜主题,并且带 class 的代码,最后两张图形同时加载,通过前端主题样式控制对应白天/黑夜模式下的图形显隐即可。

方式2

渲染时,采用 SVG 渲染,最终格式不要采用 img 标签,而是直接将 SVG 结构插入 HTML 中,而后再在前端配置对应 SVG 元素在黑夜模式下的样式即可。

后记

flexmark 很好用。PlantUML 很强大,但有一定的学习门槛。用来做博客文章的流程插图,有点杀鸡用牛刀的感觉了。