Tociyuki::Diary RSSフィード

tociyuki による Perl・Ruby・C++・C で書き散らしたコードを中心に、日常雑記も混在 : B  F  twitter  GitHub  CPAN  本館  公開鍵
 

2016年06月24日

[]less もどきの行分割処理の作り直し

less もどきの行分割処理の大枠を変更せずに、 SGR が閉じていない行表示不都合のバグをもりこんだところ、 2 つの効率の悪さが生じました。 1 つ目は行頭で、 SGR のシーケンスを見つけるために文字走査をいちいちおこなうようになったこと。 2 つ目は SGR 開始の直後で行を分割してしまう場合が生じ、 行末に SGR と SGR0 の組のゴミを出してしまうこと。 加えて、さらに不都合がもう1つ生じており、 SGR で属性変更されている文字列の途中に制御文字が存在すると、元の属性が失われてしまいます。 これらの効率の悪さと不都合を修正するには、 小手先では無理があり、 行分割処理と行表示の両方を書き直した方が楽です。

これまでは、 行の範囲の決定と行表示を分割していました。 そうなっていた理由はどこまでを行に含めるべきかの判定に重点を置いていからでした。 そこは既に一段落して、 分割された行を適切に表示することに力点が移っています。 そのためには行範囲の決定と行表示をまとめた方が都合が良いのですが、 どのように行を表示するかは行範囲の決定時に決まることで、 その後、 端末の表示幅が変化しない限りは、 ずっと変化しない性質があります。 そこで、 行分割時に、 どのように行を表示するべきかを表示スクリプトとして作成しておき、 行表示はスクリプトにしたがって最小限の手間でおこなう方針にします。

データ構造は 3 層に変化し、 上は行ごとの表示命令の範囲が並び、 中間は表示スクリプトの命令の並び、 下は従来のマルチバイト文字の並びです。 上 2 層は端末の幅が変更されるごとに作り直します。 下はストリームから読み込んだ段階で決定し、 その後は不変です。 上 2 層を text_layout_type オブジェクト、 下を text_buffer_type オブジェクトが担当するようにします。

1 つの行は、 表示カラム数の width、 表示スクリプトの最初の命令の位置 location と命令数の length の構造体で表します。 text_layout_type オブジェクトは、 この構造体のベクタをメンバに持ちます。

//@<行構造体@>=
    struct vertical_type {
        std::size_t  width;
        std::size_t  location;
        std::size_t  length;
    };

1 つの表示命令は、 オペコードと 2 つのオペランドをメンバとする構造体で表します。 オペランドは符号なし整数で、 オペコードごとに意味が変わります。 まじめに強い型付けをおこなうなら、 命令構造体ではなく、 命令オブジェクトとして、 オペランドの意味ごとに別のクラスにするべきでしょうけど、 いつもの手抜きです。

//@<表示命令構造体@>=
    struct script_type {
        int opcode;
        std::size_t position;
        std::size_t length;
    };

オペコードは 7 通りあります。 WRITE は text_buffer_type の位置 position から length バイトを表示します。 TABSKIP は length 個の空白文字を出力します。 ENTER_CTRL と EXIT_CTRL は制御文字の表示属性を変更します。 CARET_CTRL と XDIGIT は非表示オクテットの表示をします。 非表示オクテットは、 行儀が悪いやりかたなのですけど postion オペランドに値が入ります。 SGR0 は属性をノーマルにするコードを端末に送ります。

//@<表示命令のオペコード@>=
    enum {
        WRITE = 1,      // print text.c_str_at (position), length
        TABSKIP,        // print length times ' '
        ENTER_CTRL,     // print enter control mode sequence
        EXIT_CTRL,      // print exit control mode sequence
        CARET_CTRL,     // print ^A
        XDIGIT,         // print <ff>
        SGR0            // print \E[m
    };

上の説明のように text_layout_type オブジェクトは 2 種類の範囲を使い分けます。 行が扱う表示命令の範囲と、 表示命令が扱うマルチバイト文字列の範囲があります。 混同を避けるために、 前者の範囲開始位置を location、 後者の範囲開始位置を position と書き分けるローカル命名規則を使うことにします。

text_layout_type は layout メンバ関数で自身の行構造体並びメンバと表示スクリプトメンバを作成し、 表示オブジェクトに表示命令構造体の参照を公開します。

class text_layout_type {
public:
//@<行構造体@>
//@<表示命令のオペコード@>
//@<表示命令構造体@>

    text_layout_type ();
    // オプション・メンバ変数の設定
    void set_tab_width (int const value)  { m_tab_width = value; }
    void set_cjk_width (bool const value) { m_cjk_width = value; }
    void set_sgr_text (bool const value)  { m_sgr_text = value; }

    // レイアウトをおこなう
    void layout (std::size_t const window_width, text_buffer_type const& text);

    // 行が空か? 行の末尾は?
    bool empty (void) const { return m_vertical.empty (); }
    std::size_t size (void) const { return m_vertical.size (); }

    // 行の表示幅を得る
    std::size_t width_at (std::size_t const line_index) const
    {
        return m_vertical[line_index].width;
    }

    // 行の表示スクリプトの範囲の先頭 first と末尾 last を得る
    std::size_t first_at (std::size_t const line_index) const
    {
        return m_vertical[line_index].location;
    }

    std::size_t last_at (std::size_t const line_index) const
    {
        return m_vertical[line_index].location + m_vertical[line_index].length;
    }

    // 表示命令構造体の参照を得る
    script_type const& script_at (std::size_t const location) const
    {
        return m_script[location];
    } 

private:
//@<プライベート・メンバ関数宣言@>
    int m_tab_width;
    bool m_cjk_width;
    bool m_sgr_text;
    std::vector<vertical_type> m_vertical;
    std::vector<script_type> m_script;
    std::vector<std::size_t> m_sgrlist;
};

3 層のデータ構造の下層 text_buffer_type は、 マルチバイト文字と文字の表示幅を対応付けて上の層から利用できるように便宜を図ります。 上の層のために、 position にある文字の幅等を得るメンバ関数を提供します。 size メンバ関数は position の上限を表します。 下限はゼロです。 forward メンバ関数で後ろの文字の position を知ることができます。 width_at は表示幅を、 length_at はマルチバイト文字のオクテット数を、 octet_at は先頭オクテットを返します。 他にもメンバ関数はありますが、 text_layout_type が使うのは以上です。 なお、前まではあいまいな表示幅は読み込み時に半角か全角か決定してしまっていましたが、 今回からはあいまいな表示幅は 3 のままとして、 これを半角で扱うか全角とするかは text_layout_type が決めるように方針を変更しました。

class text_buffer_type {
public:
    // 略
    std::size_t size (void) const;
    std::size_t forward (std::size_t const position) const;
    int width_at (std::size_t const position) const;
    int length_at (std::size_t const position) const;
    int octet_at (std::size_t const position) const;
    // 略
};

行分割をおこなう text_layout_type の layout メンバ関数は、 2 つの下請けメンバ関数を使います。 どの position まで行に収めるかを find_right_margin で探して、 見つけた position までの表示スクリプトを build_line_script で作ります。

void
text_layout_type::layout (std::size_t const window_width, text_buffer_type const& text)
{
    m_vertical.clear ();
    m_script.clear ();
    m_sgrlist.clear ();
    for (std::size_t left = 0; left < text.size (); ) {
        std::size_t const right = find_right_margin (window_width, text, left);
        build_line_script (text, left, right);
        if ('\n' == text.octet_at (right)) {
            left = text.forward (right);
        }
        else {
            left = right;
        }        
    }
}

find_right_margin は、 文字ごとの表示幅を積算して、 画面幅を越える直前の position を探します。 それで終わりとせずに、 SGR の表示幅がゼロのときに行頭に勘定するべきの SGR を行末に含めてしまっているときがあるので、 SGR を後戻りして区切り位置を補正します。 is_sgr は前と同じで、 パラメータに 1 から 9 が存在する CSI SGR のとき真にします。


static bool is_sgr (text_buffer_type const& text, int const position);
// is_sgr の定義は前と同じなので省略

std::size_t
text_layout_type::find_right_margin (std::size_t const window_width,
    text_buffer_type const& text, std::size_t position) const
{
    int line_width = 0;
    while (position < text.size ()) {
        int char_width = text.width_at (position);
        int const octet = text.octet_at (position);
        char_width = 3 != char_width ? char_width : m_cjk_width ? 2 : 1;
        if ('\n' == octet) {
            return position;
        }
        else if (0 == char_width) {
            // SGR をゼロ幅で属性にするか ^[[1m のように表示するか
            char_width = m_sgr_text ? 0 : text.length_at (position) + 1;
        }
        else if ('\t' == octet) {   // タブは展開する
            char_width = m_tab_width - line_width % m_tab_width;
        }
        else if (octet < ' ' || 0x7f == octet) {
            char_width = 2;         // ^A のような制御文字表示
        }
        else if (1 == char_width && 0x80 <= octet) {
            char_width = 4;         // <ff> のような不正オクテット表示
        }
        if (line_width + char_width > window_width) {
            break;
        }
        line_width += char_width;
        position = text.forward (position);
    }
    if (m_sgr_text) {               // SGR は行頭にあるべき
        while (0 < position) {
            std::size_t const backpos = text.backward (position);
            if (! is_sgr (text, backpos)) {
                break;
            }
            position = backpos;
        }
    }
    return position;
}

build_line_script メンバ関数は、 前の表示メンバ関数の焼き直しです。 ストリームへ出力していた箇所を表示命令生成へ置き換えてあります。

void
text_layout_type::build_line_script (text_buffer_type const& text,
    std::size_t const left, std::size_t const right)
{
    std::size_t const top_location = m_script.size ();
    if (left == right) {
        vertical_push_back (0, top_location, 0);  // 空行は表示スクリプトも空
        return;
    }
    // 行頭で SGR が閉じていないときは、 そこまでの SGR を補います
    for (std::size_t sgr_position : m_sgrlist) {
        script_push_back (WRITE, sgr_position, text.length_at (sgr_position));
    }
    int line_width = 0;                           // タブ展開用
    std::size_t write_position = left;            // WRITE 命令の position
    std::size_t position = left;
    while (position < right) {
        int char_width = text.width_at (position);
        int const octet = text.octet_at (position);
        char_width = 3 != char_width ? char_width : m_cjk_width ? 2 : 1;
        if (0 == char_width && m_sgr_text) {
            update_sgrlist (text, position);      // SGR と SGR0 で m_sgrlist を更新
        }
        else if (octet < ' ' || 0x7f == octet || (1 == char_width && 0x80 <= octet)) {
            write_position = script_print (write_position, position) + 1;
            char_width = script_control (line_width, text, position);
        }
        line_width += char_width;
        position = text.forward (position);          
    }
    script_print (write_position, position);
    if (! m_sgrlist.empty ()) {                   // 行末で SGR が閉じてないときは
        script_push_back (SGR0, 0, 0);            // SGR0 を補います
    }
    vertical_push_back (line_width, top_location, m_script.size () - top_location);
}

update_sgrlist メンバ関数は、 CSI SGR が出現すると m_sgr_list に position を加え、 SGR0 で m_sgr_list をクリアします。 この処理は行分割位置に関係なくおこなう必要があります。

void
text_layout_type::update_sgrlist (text_buffer_type const& text, std::size_t const position)
{
    if (is_sgr (text, position)) {
        m_sgrlist.push_back (position);
    }
    else {
        m_sgrlist.clear ();
    }
}

WRITE 命令を m_script に追加します。 WRITE 命令の対象は、 平文だけでなく SGR も含みます。

std::size_t
text_layout_type::script_print (std::size_t const first, std::size_t const last)
{
    if (first < last) {
        script_push_back (WRITE, first, last - first);
    }
    return last;
}

制御文字と不正オクテットは、 タブ展開とそれ以外の 2 通りがあります。 それ以外は、キャレット付きの 2 カラム表示と、 16 進数による 4 カラムに分かれます。 タブ展開でないときで、 SGR 範囲内では、 SGR0 で属性をリセットしてから制御文字モードで出力し、元の属性に戻します。 なお、 今のところ、 制御文字が連続するときに間の属性を間引かずに出力しています。

int
text_layout_type::script_control (int const line_width,
    text_buffer_type const& text, std::size_t const position)
{
    int const octet = text.octet_at (position);
    int char_width = 1;
    if ('\t' == octet) {
        char_width = m_tab_width - line_width % m_tab_width;
        script_push_back (TABSKIP, 0, char_width);
    }
    else {
        if (! m_sgrlist.empty ()) {
            script_push_back (SGR0, 0, 0);
        }
        script_push_back (ENTER_CTRL, 0, 0);
        if (octet < ' ' || 0x7f == octet) {
            char_width = text.length_at (position) + 1;
            script_push_back (CARET_CTRL, octet, 0);
        }
        else {
            char_width = 4;
            script_push_back (XDIGIT, octet, 0);
        }
        script_push_back (EXIT_CTRL, 0, 0);
        for (std::size_t sgr_position : m_sgrlist) {
            script_push_back (WRITE, sgr_position, text.length_at (sgr_position));
        }
    }
    return char_width;
}

m_vertical と m_script への追加にキャストをいちいち書くのがわずらわしいので、メンバ関数を経由してごまかしています。

void
text_layout_type::vertical_push_back (
    std::size_t const width, std::size_t const location, std::size_t length)
{
    m_vertical.push_back ({width, location, length});
}

void
text_layout_type::script_push_back (
    int const opcode, std::size_t const position, std::size_t length)
{
    m_script.push_back ({opcode, position, length});
}

2016年06月23日

[]端末入力の状態遷移機械のエラー回復処理

前回の端末入力の状態遷移機械は、判別不能でエラー停止したとき、このオブジェクトを利用する側でなんとかすることにしていました。 ところが、利用する側でなんとかしようとしても簡単ではないことが発覚してきたので、状態遷移機械にエラー回復処理を組み込むことにします。

通常の状態遷移メンバ関数 advance に加えて、 正常処理かエラー回復中かどうかを表す empty メンバ関数と、エラー回復遷移を動かす pop メンバ関数を追加します。 これら 3 つのメンバ関数によるエラー回復処理のふるまい記述を2つ。 最初のふるまいは CSI コードの途中が打ちきられて、UTF-8 の 3 オクテットのマルチバイトが始まる場合です。 なお、advance と pop の関数値は状況を表し、ゼロが遷移途上、4 が codepoint が得られたことを示します。

void
test_case1 (test::simple& ts)
{
    keyevent_type ke;
    // input  ^[[\xe3\x81\x82
    ts.ok (ke.empty (),               "empty");
    ts.ok (ke.advance (033) == 0,     "+^[ imcomplete");
    ts.ok (ke.empty (),               "empty");
    ts.ok (ke.advance ('[') == 0,     "+[  imcomplete");
    ts.ok (ke.empty (),               "empty");
    ts.ok (ke.advance (0xe3) == 4,    "+e3 codepoint");
    ts.ok (ke.codepoint () == 033,    "got 033");       // エラー発生
    ts.ok (! ke.empty (),             "not empty");     // 回復処理開始
    ts.ok (ke.pop () == 4,            "pop codepoint");
    ts.ok (ke.codepoint () == '[',    "got [");
    ts.ok (! ke.empty (),             "not empty");     // 回復中
    ts.ok (ke.pop () == 0,            "pop imcomplete");
    ts.ok (ke.empty (),               "empty");         // 復帰
    ts.ok (ke.advance (0x81) == 0,    "+81 imcomplete");
    ts.ok (ke.empty (),               "empty");
    ts.ok (ke.advance (0x82) == 4,    "+82 codepoint");
    ts.ok (ke.codepoint () == 0x3042, "got U+3042");
}

次のふるまいは UTF-8 の 3 オクテット・マルチバイト文字の途中が ASCII7 で打ちきられる場合です。このときは、打ちきられた 2 つを未処理のオクテットとして順に返してから、 ASCII7 を正常に得てエラー回復から復帰します。

void
test_case2 (test::simple& ts)
{
    keyevent_type ke;
    // input  \xe3\x81_\xe3\x81\x82
    ts.ok (ke.empty (),               "empty");
    ts.ok (ke.advance (0xe3) == 0,    "+e3 imcomplete");
    ts.ok (ke.empty (),               "empty");
    ts.ok (ke.advance (0x81) == 0,    "+81 imcomplete");
    ts.ok (ke.empty (),               "empty"); 
    ts.ok (ke.advance ('_') == 1,     "+_  octet");
    ts.ok (ke.octet () == 0xe3,       "got <e3>");      // エラー発生
    ts.ok (! ke.empty (),             "not empty");
    ts.ok (ke.pop () == 1,            "pop octet");
    ts.ok (ke.octet () == 0x81,       "got <81>");
    ts.ok (! ke.empty (),             "not empty");
    ts.ok (ke.pop () == 4,            "pop codepoint");
    ts.ok (ke.codepoint () == '_',    "got _");
    ts.ok (ke.empty (),               "empty");         // 復帰
    ts.ok (ke.advance (0xe3) == 0,    "+e3 imcomplete");
    ts.ok (ke.empty (),               "empty");
    ts.ok (ke.advance (0x81) == 0,    "+81 imcomplete");
    ts.ok (ke.empty (),               "empty");
    ts.ok (ke.advance (0x82) == 4,    "+82 codepoint");
    ts.ok (ke.codepoint () == 0x3042, "got U+3042");
}

empty と pop の名前の通り、 状態遷移機械にエラー回復処理用のリストを追加します。 empty の正体はエラー回復待ちリストの空判定にすぎません。

class keyevent_type {
public:
    keyevent_type ()
    {
        clear ();
        m_queue_top = 0;
        m_queue.clear ();
    }

    void set (std::string const& key, int value)
    {
        m_keymap.set (key, value);
    }

    void clear (void)
    {
        m_next_state = 8;
        m_codepoint = 0;
        m_keycode = 0;
        m_stroke.clear ();
    }

    // 偽: エラー回復中で pop を使い、 真: 正常なので advance を使います。
    bool empty (void) const
    {
        return m_queue_top >= m_queue.size ();
    }

    // advance と pop と type の関数値
    //    0: imcomplete, 1: octet, 2: CSI, 3: keycode, 4: codepoint
    int advance (int const octet);
    int pop (void);
    int type (void) const  { return m_next_state > 4 ? 0 : m_next_state; }

    int codepoint (void) const { return m_next_state == 4 ? m_codepoint : 0; }
    int keycode (void) const { return m_next_state == 3 ? m_keycode : 0; }
    std::string stroke (void) const { return m_stroke; }

    int octet (void) const
    {
        return m_stroke.empty () ? 0 : static_cast<unsigned char> (m_stroke[0]);
    }

private:
    int m_next_state;
    int m_keymap_state;
    char32_t m_codepoint;
    int m_keycode;
    std::string m_stroke;
    int m_queue_top;
    std::string m_queue;
    double_array_type m_keymap;
};

advance メンバ関数は、 エラー回復待ちリストにオクテットを追加してから pop メンバ関数を呼びます。 これはエラー回復中であっても advance でオクテットを追加してもかまわないことを意味します。 前と同じで octet が負のときは、入力なしを表す約束にします。 入力なしのとき、エスケープによる遷移の直後だけ特殊ケース扱いとして、エスケープ文字単独で遷移を完了します。 今回は、 それ以外の入力なしを pop メンバ関数の呼び出しと同じとします。

int
keyevent_type::advance (int const octet)
{
    if (octet >= 0) {
        m_queue.push_back (octet);      // 入力あり
    }
    else if (m_stroke == "\033") {      // 入力なしの特殊ケース
        m_next_state = 4;               // エスケープ文字単独で遷移を完了します
        return type ();
    }
    return pop ();
}

エラー回復を含む状態遷移を pop が一手に引き受けます。 前の advance_octet では UTF-8 とダブル配列を主遷移として、平行して CSI の副遷移をおこなっていました。 今回は UTF-8 と CSI を主遷移として、 平行してダブル配列遷移をするようにしました。

int
keyevent_type::pop (void)
{
    if (empty ()) {
        return m_next_state;
    }
    if (type ()) {
        clear ();       // 既に遷移が完了しているときは、初期状態に戻します。
    }
    int const octet = static_cast<unsigned char> (m_queue[m_queue_top++]);
    m_stroke.push_back (octet);
    int const state = m_next_state;
    m_next_state = 1;   // エラーは状態 1 で表すことにします。
//@<UTF-8 の遷移@>
//@<CSI と OSS3 の遷移@>
//@<ダブル配列の遷移@>
//@<OSS3 パラメータ付きのリカバリ@>
    if (1 == m_next_state) {    // エラー回復処理
        m_queue.erase (0, m_queue_top);         // 処理済みを削除して
        m_queue_top = 0;
        m_queue.insert (m_queue_top, m_stroke); // 先頭に m_stroke を戻し
        m_stroke.clear ();
        m_codepoint = static_cast<unsigned char> (m_queue[m_queue_top++]);
        m_stroke.push_back (m_codepoint);
        m_next_state = m_codepoint < 0x80 ? 4 : 1;  // 先頭 1 オクテットの遷移を強制終了します。
    }
    if (empty ()) {
        m_queue_top = 0;
        m_queue.clear ();
    }
    return type ();
}

UTF-8 の遷移はこれまでと同じで、初期状態から状態番号を減らす方向へ遷移を進めます。 前とは状態番号が増えている点だけが異なっています。

//@<UTF-8 の遷移@>=
    if (8 > state) {
        if ((octet & 0xc0) == 0x80) {
            m_codepoint = (m_codepoint << 6) | (octet & 0x3f);
            m_next_state = state - 1;
        }
    }
    else if (8 == state) {
        if (octet < 0x80) {
            m_codepoint = octet;
            m_next_state = 4;
        }
        else if ((octet & 0xe0) == 0xc0) {
            m_codepoint = octet & 0x1f;
            m_next_state = 5;
        }
        else if ((octet & 0xf0) == 0xe0) {
            m_codepoint = octet & 0x0f;
            m_next_state = 6;
        }
        else if ((octet & 0xf8) == 0xf0) {
            m_codepoint = octet & 0x07;
            m_next_state = 7;
        }
    }

CSI と OSS3 は状態を整理しました。 OSS3 に CSI のパラメータを追加するバグな端末エミュレータがあるので、状態 10 で受け入れるようにしています。 CSI は、xterm マウス・プロトコル時の付属オクテットの読み込み遷移も含んでいます。

//@<CSI と OSS3 の遷移@>=
    else if (9 == state) {
        m_next_state = '[' == octet ? 11 : 'O' == octet ? 10 : 1;
    }
    else if (10 == state) {
        m_next_state
            = '0' <= octet && octet <= '9' ? 10 : ';' == octet ? 10
            : '@' <= octet && octet <= '~' ? 2
            : 1;
    }
    else if (11 == state) {
        m_next_state
            = '<' <= octet && octet <= '?' ? 12
            : '0' <= octet && octet <= '9' ? 12 : ';' == octet ? 12
            : ' ' <= octet && octet <= '/' ? 13
            : 'M' == octet ? 17 : 'T' == octet ? 14 : 't' == octet ? 18
            : '@' <= octet && octet <= '~' ? 2
            : 1;
    }
    else if (12 == state) {
        m_next_state
            = '0' <= octet && octet <= '9' ? 12 : ';' == octet ? 12
            : ' ' <= octet && octet <= '/' ? 13
            : '@' <= octet && octet <= '~' ? 2
            : 1;
    }
    else if (13 == state) {
        m_next_state = '@' <= octet && octet <= '~' ? 2 : 1;
    }
    else if (14 <= state && state <= 19) {
        m_next_state = 19 == state ? 2 : state + 1;
    }

ダブル配列の最初の遷移は CSI・OSS3 のエスケープ・シーケンスの開始遷移も兼ねています。

//@<ダブル配列の遷移@>=
    if (4 == m_next_state && 8 == state) {
        m_keymap_state = m_keymap.start (octet, m_keycode);
        m_next_state
            = 1 == m_keymap_state ? 3   // keycode が得られたので遷移完了
            : 033 == octet ? 9          // CSI・OSS3 の遷移
            : 2 == m_keymap_state ? 20  // ダブル配列だけの遷移
            : m_next_state;             // codepoint が得られたので遷移完了
    }
    else if (8 < state && 2 == m_keymap_state) {
        m_keymap_state = m_keymap.advance (octet, m_keycode);
        m_next_state
            = 1 == m_keymap_state ? 3
            : 0 == m_keymap_state || m_next_state > 4 ? m_next_state
            : 20;
    }

OSS3 に CSI のパラメータがあるバグ対応で、 とりあえず CSI とみなしてダブル配列に登録されていないかを調べています。

//@<OSS3 パラメータ付きのリカバリ@>=
    if (2 == m_next_state && 10 == state && 3 < m_stroke.size ()) {
        m_stroke[1] = '[';
        m_keycode = m_keymap.get (m_stroke, 0);
        m_next_state = m_keycode > 0 ? 3 : 2;
        m_stroke[1] = 'O';
    }

2016年06月21日

[]less もどきのバグ修正: SGR が閉じていない行表示不都合

端末にいろんな色でいろんな長さの行を less もどきで表示していたら、 行が文字属性が閉じないとき、 続く行を正しく表示しない不都合があることに気がつきました。 less もどきは、 文字属性を変更するエスケープ・コード SGR (Select Graphic Rendering: \E[31m 等)を、 単純に端末に通過させているだけなので、 行分割で表示がおかしくなるのは当たり前でした。

これを修正するには、 SGR と、 それを終了する SGR0 (\E[m\E(B 等)のエスケープ・コードで囲まれている範囲を追跡します。 行分割のときに、 行末が SGR の範囲内にあるときは、 範囲を開始した SGR の出現位置を行情報に加えておくことにします。 これで、 前の行の行末が SGR の範囲内になっているときは、 行頭で対応する SGR を出力し、 現在の行の行末が SGR の範囲内のときは、 行末で SGR0 を補うことで正しい表示をすることができます。 記録のためのメンバと、 SGR と SGR0 を認識する check_sgr メンバ関数が必要になるので、 text_type クラスに追加します。

class text_type {
public:
    struct box_type {
        int pos, len, width;
        // 行末が SGR 範囲内のときは、 pos_sgr は範囲を開始した SGR の位置を示します。
        // pos_sgr == std::string::npos は行末が SGR 範囲外であることを示します。
        std::size_t pos_sgr;    // 追加
    };
    // 途中略
    int check_sgr (int const pos, int const char_len);
    // 途中略
};

SGR の形式判定は文字分割のときに済んでいるので、 check_sgr は、 SGR か SGR0 のどちらであるかの判定だけをします。 1 から 9 を一つでも含んでいると SGR、そうでないときは SGR0 とすれば良いでしょう。 SGR なら 2 を、 SGR0 なら 1 を返します。

//      \E[[0;]*[1-9][0-9;]*m   SGR  (2)
//      other                   SGR0 (1)
int
text_type::check_sgr (int const pos, int const char_len)
{
    if ('[' == content[pos + 1] && 'm' == content[pos + char_len - 1]) {
        for (int i = 2; i < char_len - 1; ++i) {
            char const octet = content[pos + i];
            if ('1' <= octet && octet <= '9') {
                return 2;
            }
        }
    }
    return 1;
}

行分割をおこなう split_vbox メンバ関数に SGR 範囲検出機能を組み込みます。 範囲検出は単純で、SGR 範囲外で状態を始めて、 範囲外で SGR が見つかると範囲内の状態に遷移します。 範囲内で SGR0 が見つかると範囲外に状態を遷移します。 範囲を開始した SGR の位置を pos_sgr に格納し、範囲外では、この変数には npos を入れておきます。 これで、 行分割情報を記録するときに pos_sgr を追加すると、 行末が範囲内かどうかの判定ができるようになります。 ところで、行分割情報には、行頭が範囲内か、もしくは行末が範囲内かの、2通りのやりかたがあります。 どちらも意味は一緒ですけど、 行頭が範囲内かどうかの記録をするには作業変数を追加しないといけないので、 今回は行末が範囲内かどうかの記録をするやりかたを採用しました。

void
text_type::split_vbox (int const window_width)
{
    vbox.clear ();
    // 分割した行の行末が SGR 範囲に含まれているかどうかの追跡機能を追加します。
    bool in_sgr = false;                        // 追加 SGR 範囲で真
    std::size_t pos_sgr = std::string::npos;    // 追加 SGR 範囲を開始した SGR の位置
    int line_pos = 0;
    int line_width = 0;
    for (int pos = 0; pos < hbox.size (); ) {
        char const c = content[pos];
        int char_width = static_cast<unsigned char> (hbox[pos]) >> 6;
        int const char_len = hbox[pos] & 63;
        if ('\t' == c) {
            char_width = tab_width - line_width % tab_width;
        }

        // 追加 SGR ... SGR0 の範囲を検出します。
        if (0 == char_width && 3 <= char_len && 033 == c) {
            int const sgr_type = check_sgr (pos, char_len);
            if (! in_sgr && 2 == sgr_type) {
                pos_sgr = pos;
                in_sgr = true;
            }
            else if (in_sgr && 1 == sgr_type) {
                pos_sgr = std::string::npos;
                in_sgr = false;
            }
        }

        if ('\n' != c && line_width + char_width <= window_width) {
            line_width += char_width;
        }
        else {
            // 修正
            // 行末が SGR 範囲に含まれているときは pos_sgr が npos でなくなります。
            vbox.push_back ({line_pos, pos - line_pos, line_width, pos_sgr});

            if ('\n' == c) {
                line_pos = pos + char_len;
                line_width = 0;
            }
            else {
                line_pos = pos;
                line_width = '\t' == c ? tab_width : char_width;
            }
        }
        pos += char_len;
    }
}

行を表示する print_vbox メンバ関数で、上で記録した行末が SGR 範囲かどうかの情報を利用して、 行頭で SGR を、行末で SGR0 を補うようにします。 行頭では、 前の行の行末が範囲に含まれているとき、 現在の行頭も範囲に含まれているので、 SGR 範囲の開始位置から行頭の位置までに含まれている SGR を全部出力します。 一文字ごと探していく素朴な書き方になっており、 SGR の出現位置をリンクするよう手直しした方が良いかもしれません。

void
text_type::print_vbox (terminfo_output_type& oterm, int const idx)
{
    int line_width = 0;
    int pos_print = vbox[idx].pos;
    int const limit = pos_print + vbox[idx].len;

    if (ansi_sgr && 0 < idx && vbox[idx - 1].pos_sgr != std::string::npos) {
        // 行頭で、 前の行の行末が SGR 範囲に含まれているとき、
        // SGR 開始位置から行頭までに出現する、すべての SGR を出力します。
        int pos_sgr = vbox[idx - 1].pos_sgr;
        while (pos_sgr < pos_print) {
            int char_width = static_cast<unsigned char> (hbox[pos_sgr]) >> 6;
            int const sgr_len = hbox[pos_sgr] & 63;
            if (0 == char_width && 033 == content[pos_sgr]) {
                oterm.out.write (&content[pos_sgr], sgr_len);
            }
            pos_sgr += sgr_len;
        }
    }

    // 行の内容を出力します (省略)

    // 行末が SGR 範囲に含まれているときは SGR0 を出力します。
    if (ansi_sgr && vbox[idx].pos_sgr != std::string::npos) {
        oterm.putp (exit_attribute_mode);
    }
}