ExcelとかWordでクリップボードにコピーしたデータからAsciiDocの表に変換するツールをつくってみた（結合セル対応）

AsciiDocで結合セルを作るのがややこしい（といってもHTMLと同様だが）ので、AsciiDocに不慣れでも表のひな形を作成できるように、Excelの表からAsciiDocに変換するツールを作ってみた。
Excel用に作ってたらWordも小変更で対応できた。（セルの中身の処理はテキトウにつくったので、過度な期待は禁物。）

[オンライン版(CodePen)](https://qiita.com/kob58im/items/8c3c609b828afbc8fec2#javascript%E3%81%AB%E7%A7%BB%E6%A4%8D%E3%81%97%E3%81%A6web%E4%B8%8A%E3%81%AB%E5%85%AC%E9%96%8B%E3%81%97%E3%81%A6%E3%81%BF%E3%81%9F)

補足：
Word → AsciiDocは下記で紹介されている方法でいけるらしい（自分はまだ試してない）。
[脱Word、脱Markdown、asciidocでドキュメント作成する際のアレコレ - Qiita](https://qiita.com/tamikura@github/items/5d3f62dae55617ee42bb#worddocx%E3%82%92asciidoc%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%81%AB%E5%A4%89%E6%8F%9B%E3%81%99%E3%82%8B)


# スクリーンショット
## Excel
環境はExcel2019
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/438183/6856e171-802a-d1fe-12e7-554d796e2f40.png)

実行結果：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/438183/b45b5fce-7027-1205-f013-cce59e6f35d1.png)

```text:上記の出力結果のコピー

|===
.2+|a |b |c |d 2.2+|e
.2+|f |g |h
|i .2+|j |k |l |m
2+|n |o |p |q
|===
```

asciidoctorで変換した結果：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/438183/cbd38926-1d26-e9ae-6d1b-e508ae6863d1.png)



 
## Word
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/438183/b2695dd6-cc20-2241-2b66-a1a0460caf5b.png)

実行結果：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/438183/67d58393-c8d6-4877-a880-200e5caa6455.png)

```text:上記の出力結果のコピー

|===
|   A    |   B    |   C   
2+|   D    .2+|   E   
|   F    |   G   
|===
```

asciidoctorで変換した結果：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/438183/8ce720c8-de91-59c5-2884-4f0d644e514c.png)


# C＃ソースコード

- 処理対象は表１個だけです。
- ネストした表（テーブル内にテーブルがある）には対応していません。
- 書式は無視します。

```csharp:ClipboardedHtmlToAsciiDocTable.cs

using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Windows.Forms;

class SampleForm : Form
{
    static readonly int ExpectedHeaderMaxLines = 20;
    static readonly Regex rxTableBeginTag = new Regex(@"<table(?:\s)?[^>]*>", RegexOptions.Multiline | RegexOptions.IgnoreCase);
    //                                         1                   2             3                             *?は最短マッチ   o:pはMS office(word)対策
    static readonly Regex rxTag = new Regex(@"<([a-z][a-z0-9]*|o:p)(|\s[^>]*)>|</([a-z][a-z0-9]*|o:p)>|<!--(?:.*?)-->", RegexOptions.Multiline | RegexOptions.IgnoreCase);
    //static readonly Regex rxTag = new Regex(@"<([a-z][a-z0-9]*)(|\s[^>]*)>|</([a-z][a-z0-9]*)>|<!--(?:.*?)-->", RegexOptions.Multiline | RegexOptions.IgnoreCase);


    TextBox txtAdoc;
    
    SampleForm()
    {
        Text = "HTML table(Clipborad) to AsciiDoc";
        ClientSize = new Size(700, 430);

        var btn = new Button(){
            Size = new Size(280, 25),
            Text = "Get AsciiDoc from Clipborad",
        };
        btn.Click += (s,e)=>{ParseFromHtmlClipboard();};
        Controls.Add(btn);

        var btnDbg = new Button(){
            Location = new Point(300, 0),
            Size = new Size(220, 25),
            Text = "Get HTML from Clipborad(開発者用)",
        };
        btnDbg.Click += (s,e)=>{DumpHtmlClipboard();};
        Controls.Add(btnDbg);
        
        txtAdoc = new TextBox(){
            Location = new Point(0,30),
            Size = new Size(700, 400),
            Text = "",
            Multiline = true,
            WordWrap = false, // 折り返し表示をしない
            ScrollBars = ScrollBars.Both,
        };
        Controls.Add(txtAdoc);
        txtAdoc.KeyDown += (s,e)=>{ if (e.Control && e.KeyCode == Keys.A) { ((TextBox)s).SelectAll(); } };
        
        Resize    += (s,e)=>{MyResize();};
        ResizeEnd += (s,e)=>{MyResize();};
    }

    void MyResize()
    {
        int h = ClientSize.Height - txtAdoc.Top;
        if(h<50){h=50;}
        txtAdoc.Size = new Size(ClientSize.Width, h);
    }
    
    void ParseFromHtmlClipboard()
    {
        MemoryStream ms = GetHtmlClipboard();
        if ( ms != null ) {
            string tmp = Parse(ms);
            if ( tmp != null ) {
                txtAdoc.Text = tmp;
                txtAdoc.Focus();
                txtAdoc.SelectAll();
            }else {
                txtAdoc.Text = "Parse Failed";
            }
        }
        else {
            txtAdoc.Text = "Clipboard Load failed";
        }
    }

    void DumpHtmlClipboard()
    {
        MemoryStream ms = GetHtmlClipboard();
        if ( ms != null ) {
            string tmp = GetHtmlText(ms);
            if ( tmp != null ) {
                txtAdoc.Text = tmp;
                //txtAdoc.Focus();
                //txtAdoc.SelectAll();
            }else {
                txtAdoc.Text = "Parse Failed";
            }
        }
        else {
            txtAdoc.Text = "Clipboard Load failed";
        }
    }

    static MemoryStream GetHtmlClipboard()
    {
        return Clipboard.GetData("Html Format") as MemoryStream;
    }

    static string GetHtmlText(MemoryStream ms)
    {
        int startHtml = -1;
        int endHtml = -1;

        // ヘッダ情報(StartHTML, EndHTML)を取得
        //  StartHTML:nnnnnnnnnn
        //  EndHTML:nnnnnnnnnn
        //public StreamReader(Stream stream, Encoding encoding, bool detectEncodingFromByteOrderMarks, int bufferSize, bool leaveOpen)
        //  leaveOpen=trueで開かないと、msが閉じてしまう。
        using ( var sr = new StreamReader(ms, Encoding.UTF8, true, 1024, true) ) {
            Regex rx = new Regex(@"^(StartHTML:|EndHTML:)([0-9]+)");
            int lineCount = 0;
            string s;
            while ( (s = sr.ReadLine()) != null )
            {
                lineCount++;
                Match m = rx.Match(s);
                if ( m.Success ) {
                    int n = Convert.ToInt32(m.Groups[2].Value, 10); // 10進
                    if ( m.Groups[1].Value == "StartHTML:" ) {
                        startHtml = n;
                    }
                    else {
                        endHtml = n;
                    }
                    if ( startHtml >= 0 && endHtml > startHtml ) {
                        break;
                    }
                }
                if ( lineCount >= ExpectedHeaderMaxLines ) {
                    break;
                }
            }
        }
        
        // HTML部分を取得(EndHTMLは無視)
        ms.Position = startHtml;
        using ( var sr = new StreamReader(ms, Encoding.UTF8, false) )
        {
            return sr.ReadToEnd();
        }
    }
    
    // stypeタグの中身を返す
    static string GetStyleText(string htmlText, out int endPos)
    {
        endPos = 0;
        int styleStartTagPos = htmlText.IndexOf("<style>");
        if ( styleStartTagPos < 0 ) {
            return null;
        }
        styleStartTagPos += "<style>".Length;

        int styleEndTagPos = htmlText.IndexOf("</style>", styleStartTagPos);
        if ( styleEndTagPos < 0 ) {
            return null;
        }
        endPos = styleEndTagPos + "</style>".Length;
        
        int commentStartTagPos = htmlText.IndexOf("<!--", styleStartTagPos);
        if ( commentStartTagPos >= 0 ) {
            commentStartTagPos += "<!--".Length;
        }
        int commentEndTagPos   = (commentStartTagPos<0)?-1:htmlText.IndexOf("-->", commentStartTagPos);
        if (commentStartTagPos >= 0 && commentEndTagPos > commentStartTagPos && commentEndTagPos < styleEndTagPos ) {
            // コメントタグがある場合、コメントタグを除去（コメントタグ内のみを返す）
            return htmlText.Substring(commentStartTagPos, commentEndTagPos - commentStartTagPos);
        }
        else {
            // コメントタグがない場合
            return htmlText.Substring(styleStartTagPos, styleEndTagPos - styleStartTagPos);
        }
    }

    static int IndexOfUsingRegex(string src, int startPos, Regex rTarget, out int length)
    {
        Match m = rTarget.Match(src, startPos);
        if ( !m.Success ) {
            length = 0;
            return -1;
        }
        length = m.Groups[0].Length;
        return m.Groups[0].Index;
    }

    // tableタグ込みで返す
    // ネストは許容しない(検出してnullを返す)
    static string GetFirstTableText(string htmlText, int pos)
    {
        int len;
        int tableStartTagPos = IndexOfUsingRegex(htmlText, pos, rxTableBeginTag, out len);
        if ( tableStartTagPos < 0 ) {
            return null;
        }

        int tableEndTagPos = htmlText.IndexOf("</table>", tableStartTagPos+len);
        if ( tableEndTagPos < 0 ) {
            return null;
        }
        int dummy;
        int tmpPos = IndexOfUsingRegex(htmlText, tableStartTagPos+len, rxTableBeginTag, out dummy);

        if ( tmpPos>=0 && tmpPos<tableEndTagPos ) {
            // ネストしている（閉じタグよりも手前の位置に2つ目の開始タグを検出した）
            return null;
        }

        tableEndTagPos += "</table>".Length;
        return htmlText.Substring(tableStartTagPos, tableEndTagPos - tableStartTagPos);
    }


    //static readonly Regex rxCss = new Regex(@":;", RegexOptions.Multiline | RegexOptions.IgnoreCase);
 
    //static Dictionary<string,Dictionary<string,string>> ParseCssPart(string styleText)
    //{
    //}

    // https://momdo.github.io/html/syntax.html#attributes-2
    //  属性名は、制御文字、U+0020 SPACE、U+0022（"）、U+0027（'）、U+003E（>）、U+002F（/）、U+003D（=）、および非文字以外の1つ以上の文字で構成されなければならない。HTML構文において、外来要素に対するものでさえ、属性名は、ASCII小文字およびASCII大文字の任意の組み合わせで書かれてもよい。
    //  属性値は、テキストが曖昧なアンパサンドを含めることができない追加の制限をもつ場合を除き、テキストおよび文字参照の混合物である。
    // 引用符で囲まれない属性値構文
    //  ASCII空白文字 U+0022 QUOTATION MARK文字（"）、
    //                U+0027 APOSTROPHE文字（'）、U+003D EQUALS SIGN文字（=）、
    //                U+003C LESS-THAN SIGN文字（<）、U+003E GREATER-THAN SIGN文字（>）、
    //             またはU+0060 GRAVE ACCENT文字（`）文字を含んではならず、かつ空文字列であってはならない。

    //                                           1                                            =      2                                   3            4 
    //                                           <------------------------------------->             <-------------------------------->  <----->      <------->
    //                                                                                            <---------------------------------------------------------------->
    static readonly Regex rxAttr = new Regex(@"\b([^\x00-\x1F\x20\x22\x27\x2F\x3D\x3E]+)\s*(?:=\s*(?:([^\x20\x22\x27\x3C\x3D\x3E\x60]+)|'([^']*)'|\x22([^x22]*)\x22))?", RegexOptions.Multiline | RegexOptions.IgnoreCase);
 
    static Dictionary<string,string> ParseAttrs(string attrsStr)
    {
        var dict = new Dictionary<string,string>();
        
        Match mAttr = rxAttr.Match(attrsStr);
        while ( mAttr.Success ) {
            string key = mAttr.Groups[1].Value.ToLower();
            
            string value = "";
            if ( mAttr.Groups[2].Length>0 ) {
                value = mAttr.Groups[2].Value;
            }
            else if(mAttr.Groups[3].Length>0) {
                value = mAttr.Groups[3].Value;
            }
            else if(mAttr.Groups[4].Length>0) {
                value = mAttr.Groups[4].Value;
            }
            else { // without "="
                // do nothing
            }
            
            if ( !dict.ContainsKey(key) ) {
                dict.Add(key, value);
            }

            mAttr = mAttr.NextMatch();
        }
        return dict;
    }

    static string EscapeContentForAdocTableCell(string s)
    {
        // Replace (string input, string replacement);
        s = rxTag.Replace(s, ""); // HTML全般のタグを消去
        s = s.Replace("\r\n", " ")
             .Replace("\n", " ")
             .Replace("\r", " ")
             .Replace("\t", " ")
             .Replace("&nbsp;", " ")
             .Replace("&lt;", "<")
             .Replace("&gt;", ">")
             .Replace("&amp;", "&")
             .Replace("|", "{VBar}"); // ADoc用
        return s;
    }

    static string ParseTableToAdoc(string tableText)
    {
        var sb = new StringBuilder();



        Match m = rxTag.Match(tableText);
        string lastStartTag = null;
        int lastPos = -1;
        int lastTdPos = -1;
        int currentTdCount = 0;

        sb.AppendLine("|===");

        while (m.Success) {
            if ( m.Groups[1].Length > 0 ) {
                string tag = m.Groups[1].Value;
                string attrsStr = m.Groups[2].Value;
                lastPos = m.Groups[0].Index + m.Groups[0].Length;

                if ( tag == "tr" ) {
                    currentTdCount = 0;
                }
                else if ( tag == "td" ) {
                    lastTdPos = lastPos;
                    if ( lastStartTag != "tr" ) {
                        sb.Append(" ");
                    }
                    var attrs = ParseAttrs(attrsStr);
                    if (attrs.ContainsKey("colspan") || attrs.ContainsKey("rowspan")){
                        if ( attrs.ContainsKey("colspan") ) {
                            sb.Append(attrs["colspan"]);
                        }
                        if ( attrs.ContainsKey("rowspan") ) {
                            sb.Append(".");
                            sb.Append(attrs["rowspan"]);
                        }
                        sb.Append("+");
                    }
                    sb.Append("|");
                    currentTdCount++;
                }
                lastStartTag = tag;
            }
            else if ( m.Groups[3].Length > 0 ) {
                string tag = m.Groups[3].Value;
                int tagStartPos = m.Groups[0].Index;
                //Console.WriteLine("</" + m.Groups[3].Value +">");
                if ( tag == "tr" ) {
                    sb.AppendLine("");
                }
                else if ( tag == "td" ) {
                    string s = tableText.Substring(lastTdPos, tagStartPos - lastTdPos);
                    sb.Append(EscapeContentForAdocTableCell(s));
                    lastTdPos = -1;
                }
                lastPos = -1;
                lastStartTag = null;
            }

            m = m.NextMatch();
        }

        sb.AppendLine("|===");
        
        Console.WriteLine(sb.ToString());

        return sb.ToString();
    }

    static string Parse(MemoryStream ms)
    {
        // debug code {
        //string htmlText = File.ReadAllText("testdata_html_excel.txt");
        
        // } end of debug code

        string htmlText = GetHtmlText(ms);
        int pos;
        string styleText = GetStyleText(htmlText, out pos)??"";
        string tableText = GetFirstTableText(htmlText, pos);
        return ParseTableToAdoc(tableText);
    }


    [STAThread]
    static void Main(string[] args)
    {
        Application.Run(new SampleForm());
    }
}
```

# JavaScriptに移植してWeb上に公開してみた

開発時メモ： クリップボード形式は MIMEタイプとして`text/html`を指定すると、オフセット情報とかのヘッダ情報なしの html が得られる。

ネストチェックはしていない。

<p class="codepen" data-height="320" data-theme-id="light" data-default-tab="result" data-user="kob58im" data-slug-hash="ExgLmrm" style="height: 320px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="TableOfHtml2AsciiDoc">
  <span>See the Pen <a href="https://codepen.io/kob58im/pen/ExgLmrm">
  TableOfHtml2AsciiDoc</a> by kob58im (<a href="https://codepen.io/kob58im">@kob58im</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://static.codepen.io/assets/embed/ei.js"></script>

[TableOfHtml2AsciiDoc - CodePen](https://codepen.io/kob58im/pen/ExgLmrm)

# 参考サイト

 - [Excelの表をクリップボードにコピーしたときのHTMLの内容を調べてみた - Qiita](https://qiita.com/kob58im/items/f5d6b8d93467ef97e9c6)
 - [ブラウザ上で動くプレビュー付きのAsciidocエディターを作ってみた - Qiita](https://qiita.com/Yamane@github/items/090973185791c174532f) <br>→ [AsciiDoc Tiny Editor](https://yamanee.info/adt_editor/)
 - [［JavaScript］クリップボードを使ったコピーとペースト - Qiita](https://qiita.com/butakoma/items/642c0ec4b77f6bb5ebcf)