koudenpaのブログ

趣味のブログです。株式会社はてなでWebアプリケーションエンジニアをやっています。職業柄IT関連の記事が多いと思います。

WinHelpをMarkdownにして編集しやすくする

最近弄っているOSSのヘルプがWinHelpで編集が厳しいので各ページをMarkdownに変換してみた。

WinHelpの構造もよく分かっていないし、特に汎用的な変換ツールにしたりはしていないのだけれど、こんな考え方で変換できるのでは? という事例になるかもしれないので備忘しておく。

f:id:koudenpa:20210322205822p:plain
ヘルプファイル(画像はchm)内容を

f:id:koudenpa:20210322205946p:plain
Markdownにして編集しやすくする(画像は変換したままなのでリンクが壊れたりしているが)

前処理 hlp -> chm 変換

WihHelpは *.hlp からHTML形式(*.chm)に変換、加えてHTMLファイルの文字コードUTF-8にしておく。

この辺りの手順は日本語対応も含めて丁寧な記事がある。

*.chmファイルは使わず、変換したHTMLファイルを使う。ので厳密にはchmではなくてchmプロジェクトへの変換かもしれない。

元のヘルプには画像をクリックすると説明が表示されるクリッカブルマップのような指定があったっぽいのけれど、それはこの変換の段階で失われていた。どのみちマークダウンでは表現できないので忘れることにした。

HTML -> Markdown 変換

HTMLができたなら、今回はこんな感じで変換してみた。

  • 各ページのHTMLファイルを {titleタグ値}.md にリネーム
    • タイトルをファイル名にしておけばWikiに移行したいとなった時にもページ名に使えるだろう
    • あるファイルの内容が直感的にわかりやすいだろう
  • リネーム内容を覚えておいてリンクをリネーム先のものに置き換え
    • これは単純にリンクを維持するための手続き
  • HTMLをマークダウンに変換
    • HTMLをマークダウンに変換するライブラリは各言語で色々あるので適当に変換すればよい

これをC#で使い捨てのコードで行った。ライブラリにはダウンロード数を見てReverseMarkdownを使ってみた。

この変換が汎用的に適用できるのかさっぱり分かっていないのでちゃんとした実行ファイルを作ったりはしていないのだけれど、他の例も眺めてみてもう少し整備してもいいかもしれない。どうせやるなら文字コード変換*1も含めて処理してしまいたい。

SRC/SRC.Sharp/HelpWork/HelpConverter at f0e68e1d92a9e089a7d40953c6813f0169863fad · 7474/SRC · GitHub

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;

namespace HelpConverter
{
    class FileConverter
    {
        static readonly Regex nameRegex = new Regex("<TITLE>(.+)</TITLE>", RegexOptions.IgnoreCase | RegexOptions.Multiline);
        static readonly Regex anchorRegex = new Regex("<A HREF=\"(.+?.htm?)\">(.+?)</A>", RegexOptions.IgnoreCase);
        static readonly Regex tagRegex = new Regex("<[^>]+>", RegexOptions.IgnoreCase);

        static readonly ReverseMarkdown.Converter converter = new ReverseMarkdown.Converter(new ReverseMarkdown.Config
        {
            // Objectタグを落とす
            UnknownTags = ReverseMarkdown.Config.UnknownTagsOption.Drop,
            // GitHubかそのWikiターゲットなので
            GithubFlavored = true,
        });
        public string SourcePath { get; private set; }
        public string DestPath { get; private set; }
        public string SourceContent { get; private set; }
        public string ConvertedContent { get; private set; }

        public FileConverter(string sourcePath)
        {
            SourcePath = sourcePath;
            SourceContent = File.ReadAllText(SourcePath);
        }

        public void ResolvePath()
        {
            DestPath = "md/" + (
                nameRegex.Match(SourceContent).Groups.Values.Skip(1).FirstOrDefault()?.Value
                ?? Path.GetFileName(SourcePath)
            ) + ".md";
        }

        public void ConvertContent(IDictionary<string, string> fileNameMap)
        {
            var tmpContent = anchorRegex.Replace(SourceContent, (m) =>
            {
                var href = m.Groups[1].Value;
                var text = m.Groups[2].Value;
                var name = tagRegex.Replace(text, "");
                string newHref;
                if (!fileNameMap.TryGetValue(href.ToLower(), out newHref))
                {
                    newHref = name + ".md";
                }
                return $"<a href=\"{newHref}\">{text}</a>";
            });
            ConvertedContent = converter.Convert(tmpContent).TrimStart();
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            Directory.CreateDirectory("md");
            var converters = args
                .Where(x => Directory.Exists(x))
                .SelectMany(x => Directory.EnumerateFiles(x, "*.html", SearchOption.AllDirectories)
                    .Concat(Directory.EnumerateFiles(x, "*.htm", SearchOption.AllDirectories)))
                .Concat(args.Where(x => File.Exists(x)))
                .Select(x => new FileConverter(x))
                .ToList();

            converters.ForEach(x => x.ResolvePath());

            var fileNameMap = converters.ToDictionary(x => Path.GetFileName(x.SourcePath).ToLower(), x => Path.GetFileName(x.DestPath));

            converters.ForEach(x =>
            {
                x.ConvertContent(fileNameMap);
                Console.Out.WriteLine($"{x.SourcePath} -> {x.DestPath}");
                File.WriteAllText(x.DestPath, x.ConvertedContent);
            });
        }
    }
}

*1:変換しなくても読み込めればいいだけだが