3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-10-30

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

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

---
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

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

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

---
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を実行して剥ぎ取っておきます。

> cmd /c "vfm 02.md > 02.html" && stripteaser 02.html 02.html
> vivliostyle preview 02.html

image.png

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

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?