4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【JavaScript】Canvasでほぼ完ぺきな縦書きをする!

Last updated at Posted at 2023-08-28

追記の追記

これ使って

追記

この方のやり方のほうが賢いと思われます。
ただし、すべてのブラウザで縦になるかは確証が持てませんでした。

sample

回転は省略

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        .vertical {
            writing-mode: vertical-rl;
        }
    </style>
</head>
<body>
    <canvas id="canvas" class="vertical"></canvas>
    <script>
        const canvas = document.querySelector("#canvas");
        const context = canvas.getContext("2d");
        context.font = "24px serif";
        context.fillText("「やったー!」と思った。", 10, 50);
    </script>
</body>
</html>

以下、昔書いた本文
興味がある人だけ読んでください。

前書き

Canvasで日本語の縦書きが面倒くさかったので関数を作りました。
回転させたい文字とかは各自でカスタマイズしてね。

結論

以下をコピペして使ってください。

tategaki.js
const tategaki = (function() {
    const trimming = function(pixels) {
        const data = pixels.data;
        let targetLeftX = -1;
        let targetRightX = -1;
        let targetTopY = -1;
        let targetBottomY = -1;
    
        // left-xを求める
        for (let col = 0; col < pixels.width; col++) {
            for (let row = 0; row < pixels.height; row++) {
                const i = row * pixels.width * 4 + col * 4;
                if (data[i + 3] !== 0) {
                    targetLeftX = col;
                    break;
                }
            }
            if (targetLeftX !== -1) {
                break;
            }
        }
    
        if (targetLeftX === -1) {
            throw new Error("文字がない!!真っ白!!");
        }
    
        // right-xを求める
        for (let col = pixels.width - 1; col >= 0; col--) {
            for (let row = 0; row < pixels.height; row++) {
                const i = row * pixels.width * 4 + col * 4;
                if (data[i + 3] !== 0) {
                    targetRightX = col;
                    break;
                }
            }
            if (targetRightX !== -1) {
                break;
            }
        }
    
        // top-yを求める
        for (let row = 0; row < pixels.height; row++) {
            for (let col = targetLeftX; col <= targetRightX; col++) {
                const i = row * pixels.width * 4 + col * 4;
                if (data[i + 3] !== 0) {
                    targetTopY = row;
                    break;
                }
            }
            if (targetTopY !== -1) {
                break;
            }
        }
    
        // bottom-yを求める
        for (let row = pixels.height - 1; row >= 0; row--) {
            for (let col = targetLeftX; col <= targetRightX; col++) {
                const i = row * pixels.width * 4 + col * 4;
                if (data[i + 3] !== 0) {
                    targetBottomY = row;
                    break;
                }
            }
            if (targetBottomY !== -1) {
                break;
            }
        }
    
        return {
            x: targetLeftX, y: targetTopY,
            width: targetRightX - targetLeftX + 1,
            height: targetBottomY - targetTopY + 1
        };
    }

    return function(font, text, tateMargin = 4, letterSpacing = 4) {
        const tmpCanvas = document.createElement("canvas");
        const tmpContext = tmpCanvas.getContext("2d", { willReadFrequently: true });
        
        // 小さい文字 デフォルトで右寄せ 半角スペースもここに属する
        const smallCharList = "、。.,っゃゅょぁぃぅぇぉッャュョァィゥェォ 「」『』()()【】";
        // 時計回りに90度回転させる文字
        const rotateCharList = "「」『』()()【】ー ~…-";
        // 反転させる文字
        const reverseCharList = "ー~";
        // 中央寄せする文字
        const centerJustifiedCharList = "()()【】…";
        // 左寄せする文字
        const leftJustifiedCharList = "」』";
        // 上寄せする文字
        const topJustifiedCharList = "、。」』";
        // 下寄せする文字
        const bottomJustifiedCharList = "「『";
        // 基準の文字 寄せの基準とか足りない余白の計算に使ったりする
        const standardChar = "";
    
        let minCanvasHeight = 0;
        const {
            width: standardCharWidth,
            height: standardCharHeight
        } = (() => {
            tmpContext.font = font;
            tmpContext.textBaseline = "top";
            tmpContext.textAlign = "center";
            const measure = tmpContext.measureText(standardChar)
            tmpCanvas.width = Math.ceil(measure.width);
            minCanvasHeight = Math.ceil(Math.abs(measure.actualBoundingBoxAscent) + measure.actualBoundingBoxDescent);
            tmpCanvas.height = minCanvasHeight;
    
            tmpContext.font = font;
            tmpContext.clearRect(0, 0, tmpCanvas.width, tmpCanvas.height);
            tmpContext.fillStyle = "#000";
            tmpContext.textBaseline = "top";
            tmpContext.textAlign = "center";
            tmpContext.fillText(standardChar, tmpCanvas.width / 2, 0);
            return trimming(tmpContext.getImageData(0, 0, tmpCanvas.width, tmpCanvas.height));
        })();
    
        // 各文字の幅、高さの抽出とか
        let tmpCanvasWidth = 0;
        let tmpCanvasHeight = 0;
        const charList = [];
        for (const char of text) {
            tmpContext.font = font;
            tmpContext.textBaseline = "top";
            tmpContext.textAlign = "center";
            const measure = tmpContext.measureText(char);
            const width = measure.width;
            const height = Math.abs(measure.actualBoundingBoxAscent) + measure.actualBoundingBoxDescent;
            let canvasWidth = width;
            let canvasHeight = height;
    
            if (rotateCharList.includes(char)) {
                canvasWidth = height;
                canvasHeight = width;
            }
    
            charList.push({
                value: char,
                width: width,
                height: height,
                canvasWidth: canvasWidth,
                canvasHeight: canvasHeight,
            });
            if (tmpCanvasWidth < canvasWidth) {
                tmpCanvasWidth = canvasWidth;
            }
            tmpCanvasHeight += Math.max(canvasHeight, standardCharHeight);
        }
    
        tmpCanvas.width = Math.ceil(tmpCanvasWidth);
        tmpCanvas.height = Math.ceil(tmpCanvasHeight) + letterSpacing * (charList.length - 1);
        tmpContext.clearRect(0, 0, tmpCanvas.width, tmpCanvas.height);
    
        const tmpCanvas2 = document.createElement("canvas");
        const tmpContext2 = tmpCanvas2.getContext("2d", { willReadFrequently: true });
        let dstY = 0;
        let maxWidth = standardCharWidth;
        let totalHeight = letterSpacing * (charList.length - 1);
        for (const char of charList) {
            const isSmallChar = smallCharList.includes(char.value);
            const isRotateCar = rotateCharList.includes(char.value);
            const isReverseChar = reverseCharList.includes(char.value);
            const isCenterJustifiedChar = centerJustifiedCharList.includes(char.value); 
            const isLeftJustifiedChar = leftJustifiedCharList.includes(char.value);
            const isTopJustifiedChar = topJustifiedCharList.includes(char.value);
            const isBottomJustifiedChar = bottomJustifiedCharList.includes(char.value);
    
            tmpCanvas2.width = Math.ceil(char.canvasWidth);
            tmpCanvas2.height = Math.max(Math.ceil(char.canvasHeight), minCanvasHeight);
    
            if (isRotateCar) {
                tmpCanvas2.width = tmpCanvas2.height = Math.max(tmpCanvas2.width, tmpCanvas2.height);
            }
    
            // テキスト反映
            tmpContext2.font = font;
            tmpContext2.clearRect(0, 0, tmpCanvas2.width, tmpCanvas2.height);
            tmpContext2.fillStyle = "#000";
            tmpContext2.textBaseline = "middle";
            tmpContext2.textAlign = "center";
    
            if (isRotateCar) {
                tmpContext2.translate(tmpCanvas2.width / 2, tmpCanvas2.height / 2);
                tmpContext2.rotate(Math.PI / 2);
                tmpContext2.translate(-tmpCanvas2.width / 2, -tmpCanvas2.height / 2);
            }
            if (isReverseChar) {
                tmpContext2.scale(1, -1);
                tmpContext2.translate(0, -tmpCanvas2.height);
            }
    
            tmpContext2.fillText(char.value, tmpCanvas2.width / 2, tmpCanvas2.height / 2);
            // トリミング
            let trimmed = null;
            try {
                trimmed = trimming(tmpContext2.getImageData(0, 0, tmpCanvas2.width, tmpCanvas2.height));
            }
            catch (e) {
                trimmed = {
                    x: 0, y: 0,
                    width: standardCharWidth,
                    height: standardCharHeight
                };
                if (char.value === " ") {
                    trimmed.height /= 2;
                }
            }
    
            // 転写
            let dstX = (tmpCanvas.width - trimmed.width) / 2;
    
            if (isSmallChar && !isCenterJustifiedChar) {
                // 右寄せ
                dstX = (tmpCanvas.width - standardCharWidth) / 2 + standardCharWidth - trimmed.width;
            }
            if (isLeftJustifiedChar) {
                // 左寄せ
                dstX = (tmpCanvas.width - standardCharWidth) / 2;
            }
            
            // 漢数字の「一」みたいな文字は必要な余白すら切り取られてしまうので対策
            let isLargeMarginChar = !isSmallChar && trimmed.height < standardCharHeight;
            // ただし、これらは除外する
            if (isLargeMarginChar) {
                // ASCII ひらかな 全角カタカナ 半角カタカナ 半角数字 全角数字 全角英字大文字 全角英字小文字
                if (/[\x00-\x7F\u3040-\u309F\u30A0-\u30FF\uFF61-\uFF9F0-90-9A-Za-z]/.test(char.value)) {
                    isLargeMarginChar = false;
                }
            }

            // 小さい文字も最低限の高さを与える
            const standardCharHalfHeight = standardCharHeight / 2;
            const isSmallMarginChar = isSmallChar && trimmed.height < standardCharHalfHeight;

            const prevDestY = dstY;
            if (isLargeMarginChar) {
                dstY += (standardCharHeight - trimmed.height) / 2;
            }
            else if (isSmallMarginChar) {
                if (isTopJustifiedChar) {
                    // 何もしない
                }
                else if (isBottomJustifiedChar) {
                    dstY += standardCharHalfHeight - trimmed.height;
                }
                else {
                    dstY += (standardCharHalfHeight - trimmed.height) / 2;
                }
            }
    
            tmpContext.putImageData(tmpContext2.getImageData(trimmed.x, trimmed.y, trimmed.width, trimmed.height), dstX, dstY);
    
            if (isLargeMarginChar) {
                dstY = prevDestY;
                dstY += standardCharHeight + letterSpacing;
                totalHeight += standardCharHeight;
            }
            else if (isSmallMarginChar) {
                dstY = prevDestY;
                dstY += standardCharHalfHeight + letterSpacing;
                totalHeight += standardCharHalfHeight;
            }
            else {
                dstY += trimmed.height + letterSpacing;
                totalHeight += trimmed.height;
            }
    
            if (maxWidth < trimmed.width) {
                maxWidth = trimmed.width;
            }
        }
    
        let yokoMargin = 0;
        tmpCanvas2.width = maxWidth + yokoMargin * 2;
        tmpCanvas2.height = totalHeight + tateMargin * 2;
        tmpContext2.clearRect(0, 0, tmpCanvas2.width, tmpCanvas2.height);
        const dstX = (tmpCanvas2.width - tmpCanvas.width) / 2;
        tmpContext2.drawImage(tmpCanvas, dstX, tateMargin);
    
        return tmpCanvas2;
    }
})();

使い方

sample.js
const canvas = document.querySelector("#tategaki-canvas");
const context = canvas.getContext("2d", { willReadFrequently: true });

const font = "400 30px 'MS Pゴシック', '游ゴシック', YuGothic, 'メイリオ', Meiryo, 'ヒラギノ角ゴ ProN W3', 'Hiragino Kaku Gothic ProN', Verdana, Roboto, 'Droid Sans', sans-serif";

const image1 = tategaki(font, "「ああ~、");
const image2 = tategaki(font, "心がぴょんぴょん");
const image3 = tategaki(font, "するんじゃ~。」");

canvas.width = 400;
canvas.height = 400;
context.fillStyle = "#98fb98";
context.fillRect(0, 0, canvas.width, canvas.height);
context.drawImage(image1, canvas.width * 3 / 4 - image1.width / 2, 10);
context.drawImage(image2, canvas.width / 2 - image2.width / 2, canvas.height / 2 - image2.height / 2);
context.drawImage(image3, canvas.width / 4 - image3.width / 2, canvas.height - image3.height - 10);
sample.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>縦書きキャンバス</title>
</head>
<body>
  <canvas id="tategaki-canvas"></canvas>

  <script src="tategaki.js"></script>
  <script src="sample.js"></script>
</body>
</html>

結果

tategaki.jsの簡単なアルゴリズムの説明

  1. 縦にしたい文字列を一文字ずつ分解し以下を行い、返り値用のキャンバスに縦に貼り付ける。
    • 文字を加工用キャンバスに貼り付け余計な余白を消す。
    • ゃゅょのような小さい文字は右寄せにする。
    • 「」【】のような文字は90度回転させる。
    • ー~のような文字は90度回転させた後、鏡文字にする。1
  2. 返り値用のキャンバスを返す。
  3. 終わり

関連記事

こっちのほうがクオリティが高いかも

  1. Pagesの縦書きの音引きの方向が誤っている

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?