Vivliostyleでコードに改行マーカーを付ける

書籍にソースコードを掲載する際、実際には入力しない組版都合の改行が発生することがあります。特に初心者向けの書籍では気を遣う要素で、改行しても支障がない箇所で前もって改行したり、[行番号を付け](https://qiita.com/u1f992/items/b30dfbee13ab47dcfdea)たり何らかの改行マーカーを付けたりして明確にします。今回はVivliostyleで改行マーカーを実現します。

VFMでソースコードを改行させるには次のようなCSSを用意します。

~~~markdown
---
lang: ja
---

<style>
@page {
    size: A5;
    margin: 15mm;
}

pre {
    background-color: #ddd;
    padding: 2mm;

    white-space: pre-wrap;
    word-break: break-all;
}

pre code {
    font-size: 12q;
}
</style>

```javascript
const extremelyLongVariableNameThatMightBeTooVerboseForRegularUseButIsStillTechnicallyValid = "Supercalifragilisticexpialidocious";
console.log(extremelyLongVariableNameThatMightBeTooVerboseForRegularUseButIsStillTechnicallyValid);
```
~~~

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1550728/b0564eb2-17b4-1330-3dd5-03a3d9265229.png)

改行された位置に照合するセレクタがあれば都合がよいのですが特にないようなので、JavaScriptを使って改行させたい位置に空の`span`要素を挿入します。CSS側で`::before`／`::after`を使用してマーカーのスタイルと改行を設定します。

VFMはPrism.jsのCSSで色付けできるようトークンごとに`span`で分けたHTMLを出力するので、壊さないようにしています。うまくすると`HTMLElement`自体から半角何文字詰められるか計算できるかもしれませんが、Webページとは異なり書籍では予め判明しているはずなのでこれで十分でしょう。さらに真剣にやるならEast Asian Widthまで考慮するとよさそうです。

~~~markdown
---
lang: ja
---

<script>
function insertLineBreakMarker(
    codeElement,
    halfwidthCharsPerLine,
    halfwidthToFullwidthRatio = 2,
    className = "line-break-marker",
    markerContent = " \u21a9",
) {
    const segmenter = new Intl.Segmenter("ja", { granularity: "grapheme" });
    function segmentalize(text) {
        return Array.from(segmenter.segment(text)).map(s => s.segment);
    }

    function isHalfwidthChar(char) {
        return segmentalize(char).length == 1 && /* not quite accurate */ !!char.match(/[ -~｡-ﾟ]/);
    }

    function calculateWidth(text) {
        return segmentalize(text)
            .map((char) => isHalfwidthChar(char) ? 1 : halfwidthToFullwidthRatio)
            .reduce((sum, width) => sum + width, 0);
    }

    const markerWidth = calculateWidth(markerContent);
    let currentWidth = 0;

    function processTextNode(textNode) {
        const textContent = segmentalize(textNode.textContent !== null ? textNode.textContent : "");
        let modifiedNodes = document.createDocumentFragment();
        let buffer = "";

        for (const char of textContent) {
            if (char === "\n") {
                modifiedNodes.appendChild(document.createTextNode(buffer + char));
                buffer = "";
                currentWidth = 0;
                continue;
            }
            const charWidth = isHalfwidthChar(char) ? 1 : halfwidthToFullwidthRatio;
            if (currentWidth + charWidth > halfwidthCharsPerLine) {
                console.debug(`insert span.${className}`);

                const segmentalized = segmentalize(buffer);

                // Determine how much text to move to the next line
                // to align the line end with the marker
                let rollback = segmentalized.length;
                let accumulated = currentWidth + markerWidth;
                while (accumulated > halfwidthCharsPerLine) {
                    rollback--;
                    accumulated -= isHalfwidthChar(segmentalized[rollback]) ? 1 : halfwidthToFullwidthRatio;
                }

                const currentLine = segmentalized.slice(0, rollback).join("");
                modifiedNodes.appendChild(document.createTextNode(currentLine));

                const lineBreakSpan = document.createElement("span");
                lineBreakSpan.className = className;
                modifiedNodes.appendChild(lineBreakSpan);

                buffer = segmentalized.slice(rollback).join("");
                currentWidth = calculateWidth(buffer);
            }
            buffer += char;
            currentWidth += charWidth;
            console.debug(`${buffer}, ${currentWidth}`);
        }
        if (buffer) {
            modifiedNodes.appendChild(document.createTextNode(buffer));
        }
        textNode.replaceWith(modifiedNodes);
    }

    function traverseNodes(node) {
        // Freeze childNodes to prevent structure changes caused by replaceWith
        const children = Array.from(node.childNodes);
        for (let child of children) {
            if (child.nodeType === Node.TEXT_NODE) {
                processTextNode(child);
            } else if (child.hasChildNodes()) {
                traverseNodes(child);
            }
        }
    }

    traverseNodes(codeElement);
}

const pageWidth = 148;
const pageHorizontalMargin = 15 + 15;
const preHorizontalPadding = 2 + 2;
const fontSize = 12 /* q */ / 4;
const halfwidthCharsPerLine = (pageWidth - (pageHorizontalMargin + preHorizontalPadding)) / fontSize * 2;

document.addEventListener("DOMContentLoaded", () => {
    const preCodeElements = Array.from(document.querySelectorAll("pre code"));
    for (const elem of preCodeElements) {
        console.log(elem)
        insertLineBreakMarker(elem, halfwidthCharsPerLine);
    }
});
</script>
<style>
@page {
    size: A5;
    margin: 15mm;
}

pre {
    background-color: #ddd;
    padding: 2mm;

    white-space: pre-wrap;
    word-break: break-all;
}

pre code {
    font-size: 12q;
}

span.line-break-marker::before {
    content: " ↩";
}

span.line-break-marker::after {
    content: "";
    display: block;
}
</style>

```javascript
const extremelyLongVariableNameThatMightBeTooVerboseForRegularUseButIsStillTechnicallyValid = "Supercalifragilisticexpialidocious";
console.log(extremelyLongVariableNameThatMightBeTooVerboseForRegularUseButIsStillTechnicallyValid);

/* ----------------------- ここまでは改行されない ----------------------- */
/*このようにキリが悪くなったら半角手前に改行マーカーが付く。クラス名を分ければ揃えることもできそう */
```
~~~

なおVivliostyleで処理する前に、埋め込んだJavaScriptを実行して剥ぎ取っておきます。

```shell-session
> cmd /c "vfm 02.md > 02.html" && stripteaser 02.html 02.html
> vivliostyle preview 02.html
```

https://github.com/u1f992/stripteaser

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1550728/0b0aa9e2-1f0c-4fd3-2ff3-2adf9e8c884d.png)

Vivliostyleでコードに改行マーカーを付けることができました。