5.14 [C#]特殊な木構造のデータ管理と図への描画


Home -> 雑用 -> 雑用メモ -> [5.14 [C#]特殊な木構造のデータ管理と図への描画]

2015/09/28 作成
2015/09/28 追記1
2015/09/28 追記2
一切推敲していない糞文章故、大変読み難い代物となっております。

要約

某掲示板の系譜を綺麗に作り直そうという試み。
Excelやらペイントやらを使えば誰でも作れるけど敢えてC#で実現するところに意味がある。

概要

どうして突然こんなことをしようと思い立ったのかというと、下のような画像を機械で自動的に描画させたかったから。
この画像は普通のペイントソフトを用いて作成され、幾度となく編集しては版を改めているようである。
家系図作成ソフトは世に腐るほどあるが、それを使ってこういう画像をつくる方法は今のところなさそう。

特殊な木構造の例 画像00: 特殊な木構造の例(引用元:http://dl1.getuploader.com/g/kereseu/68/krswhistory21.5.png)

形式はPNGだし、ぱっと見では綺麗に作られた画像である。
しかし少し注意して見ると、枝がよれていたり、行間や枠の余白がバラバラだったりと改善の余地を感じる。
さらに、追記編集を繰り返す過程でJPEGを経由したせいであろうか、至る所にノイズが混じっている。
[画像01]は[画像00]の一部を切り出してノイズを顕在化させたものである。

[画像00]のノイズ 画像01: [画像00]のノイズ

また、[画像00]に示されるデータ構造は通常の木構造とは異なる特徴を有している。
普通の木構造ならあるノードの子ノードは全て同じ列、すなわち親ノードの右側の領域に描画すればよい。
しかし、上の図では子ノードが親ノードの右側だけでなく下や上にも描画されている。
それもただバラバラに枝を生やしているのではなく、枝の方向によりノードの性質を区別している。
こうした構造をどう管理して描画時の処理をどう実装するかが今回の主な問題である。

一覧

  1. 木構造の概要と管理
  2. データの管理方法
  3. 単純な木構造の描画
  4. 特殊な木構造の描画[1]: 子ノードの描画領域の調整
  5. クラスと構造体、値型と参照型、プロパティとフィールド
  6. 特殊な木構造の描画[2]: 親ノードの描画領域の調整
  7. 木構造の改良・描画方法の改良
  8. ベクタ形式データの出力
    1. その1: PDF生成ライブラリ「PDFsharp」
    2. その2: プリンタ「PageManager PDF Writer」経由の出力
    3. その3: プリンタ「Microsoft XPS Document Writer」経由の出力
    4. その4: Webサービスによる OXPS→PDF の変換
    5. その5: プリンタ「PDF24 PDF」・「Bullzip PDF Printer」経由の出力
    6. その6: CreateFont API によるフォントの調整
    7. その7: メタデータの出力とPDFの作成
    8. その8: C#単体でのPDF出力
  9. 線の描画方法の改良
  10. タイトル・凡例・背景画像の挿入
  11. まとめ
  12. 補足
  13. 追記1 (2015/09/28-1)
  14. 追記2 (2015/09/28-2)

木構造の概要

木構造というのはデータ構造の一種。プログラム上ではよく配列やらポインタやら構造体+カーソルやらを用いてその親子関係を管理する。

.NET上ではこれを管理するのにちょうどいいTreeViewコントロールなんてのがあるので、できればこれを使いたい。
データ構造管理用UIを独自に設計なんてなったら面倒だし。

また家系図では親が複数(最大で2つ、かつほとんどの場合は2つ)存在するが、木構造では親は高々1つしか存在しない。
この制限のお蔭でデータ構造がいくらか簡単になる。

データの管理方法

TreeViewだと子ノードは全部一緒くたに管理されてしまう。せいぜい順番が付いているくらい。
仕方がないので他のタグ情報と一緒にノードの性質に関する情報はTagに全部突っ込んで解決。
投げやりだけどしゃーない。ぱっと見では性質の違いが区別できないのが残念だがこのまま突き進む。

Tagに何を突っ込むかは色々とやり方がある。例えばノードに紐づける情報を全部まとめたオブジェクトを突っ込むとか。
今回はノードに紐づける情報をオブジェクトのListで管理し、Tagにインデックスを突っ込んだ。
こうすることでオブジェクトのシリアライズをTreeViewのシリアライズと切り離せるのでコードが若干書きやすくなる。

public partial class Form1 : Form
{
    /// <summary>
    /// 板リスト
    /// </summary>
    public List<Board> ListBoard = new List<Board>();
    
    private void treeView1_AfterSelect(object sender, TreeViewEventArgs e)
    {
        selectedNodeIndex = (int)this.treeView1.SelectedNode.Tag;
        selectedBoard = this.ListBoard[selectedNodeIndex];
        
        this.textBoxName.DataBindings.Clear();
        this.textBoxName.DataBindings.Add("Text", selectedBoard, "name");
        this.textBoxUrl.DataBindings.Clear();
        this.textBoxUrl.DataBindings.Add("Text", selectedBoard, "url");
        //後はデータバインドとかいろいろ適当に書いておく
    }
}
public class Board
{
    public string name { get; set; }
    public string url { get; set; }
    //これでノードの性質を管理する
    //0なら右に枝を生やす、1なら下に枝を生やす、といった感じ
    public decimal att { get; set; }// 0,1,2...
    //こんな感じで他の関連情報も格納できるようにする
    //シリアライズできない型の場合は以下のようにして頑張ってシリアライズする
    [XmlIgnore]
    public Color color { get; set; }
    [EditorBrowsable(EditorBrowsableState.Never)]
    [Browsable(false)]
    public string colorSetting
    {
        get { return ConvertToString(color); }
        set { color = ConvertFromString<Color>(value); }
    }
    public static string ConvertToString<T>(T value)
    {
        return TypeDescriptor.GetConverter(typeof(T)).ConvertToString(value);
    }
    public static T ConvertFromString<T>(string value)
    {
        return (T)TypeDescriptor.GetConverter(typeof(T)).ConvertFromString(value);
    }
}

TreeViewはシリアライズ・デシリアライズ両方ともサンプルのコードが転がっているので有り難く使わせてもらう。
バイナリでもいいけどXMLの方が見ていて楽しいのでこっちのコードを使う。
SetAttributeValueを適当に書き換えればすぐに使える。適当なクラスにでもまとめておいた方が便利かも。

単純な木構造の描画

上述の通りノードの性質(属性)は番号によって管理することにしたが、ここでは便宜のためにそれぞれ以下のように名前を付けることにする。
これらによって区別された掲示板が実際に区分名の通りとなっているとは限らないが、とりあえず描画する時に区別が必要となるのでこのように属性を定義する。

つまり下の図のような感じである。

子ノードの属性 画像02: 子ノードの属性

この区別のせいで描画処理がとてつもなく面倒になる訳だが、子ノードを全て下に描画していくだけならかなり簡単に実装できる。
つまり子ノードが葉まで辿っても全て派生しか存在しないような場合であり、このとき描画される図はTreeViewそのものである。

単純な木構造のデータ入力例 画像03: 単純な木構造のデータ入力例

上のデータを基に図を描画すると下のような感じになる。フォントがMS謹製のアレなのは他にいいのを持ってないのとめんどいから。
文字列が長いときは横方向の倍率をいい感じに調整して枠内に収まるようにする。
(後述するがこれのせいでかなり面倒なことになった。)

下のような画像を作りたいだけならアルゴリズムも糞もないので細かいことはここには書かない。
ただノードを順番に走査して上から順番に描画するだけなので。

単純な木構造の出力例 画像04: 単純な木構造の出力例

ただし、これだとノードが多くなれば多くなるほど図が下に伸びて見辛くなる。
扱いたいデータはノード数が100以上あるのでこのままでは使い物にならない。

特殊な木構造の描画[1]: 子ノードの描画領域の調整

上述のような特殊な木構造をどう処理するかが問題であるが、とりあえず以下のようにしてみた。

走査方法は下の図のような感じ。
列の幅は最後まで走査しないと決定できないので、2列目以降のノードは1回目の走査では横方向について描画位置を決定できない。
1回目の走査では縦方向の描画位置を決定しつつ列の幅をどれくらいにすればよいか監視する。
2回目の走査を行う時点では既に列の幅が決定されているので全てのノードの描画位置を決定できる。

走査順と列の幅 画像05: 走査順と列の幅

これを頑張って実装すると以下のような図が出力できるようになる。
もちろんデータは事前に全部入力しなければならない。ノード数が無駄に多くて疲れた。
描画方法についてはまだいろいろ適当に誤魔化している所があるので線が被ったりしている。
木構造としては破綻しているが雰囲気はいい感じになった。

特殊な木構造の仮出力1 画像06: 特殊な木構造の仮出力1

クラスと構造体、値型と参照型、プロパティとフィールド

コードを書いていて初めて気付いたがRectangleはクラスじゃなくて構造体らしい。
クラスだと思って適当なコード書いてたら全然思ったとおりに動かなくて焦った。

構造体は値型なので、あるクラスのプロパティになっている構造体をクラスだと思って弄ろうとするとコンパイルすら通らない。
例えばRectangleの縦方向の位置を10だけ下にずらしたい、とかいうときには下みたいな感じで適当に書いてやる。
C#は何でもかんでも参照型だと思って適当に書いたり、自動プロパティ実装を乱用しまくっていると引っ掛かる。

public class Board
{
    // こんな感じでRectangleをプロパティにしておくと
    public Rectangle Rect { get; set; }
    public Board()
    {
        this.Rect = new Rectangle(20, 40, 60, 80);
        // (残りは省略)
    }
}
public partial class Form1 : Form
{
    private void test(){
        Board b = new Board();
        
        // こういう書き方をした時にコンパイルが通らない
        b.Rect.Y += 10;
        
        // これはコンパイルは通るけど上手くいかない
        // 期待される出力は 40 + 10 = 50
        Rectangle r = b.Rect;
        r.Y += 10;
        Debug.WriteLine(r.Y); // 50
        Debug.WriteLine(b.Rect.Y); // 40
        
        // こう書けば手抜きだけどとりあえず動く
        // r に b.Rect を複製してそっちを弄った後で b.Rect に入れる
        Rectangle r = b.Rect;
        r.Y += 10;
        b.Rect = r;
        Debug.WriteLine(r.Y); // 50
        Debug.WriteLine(b.Rect.Y); // 50
        
        // こう書いてもいいけど個人的にめんどい気がする
        // 目的の値を格納する構造体を新しく作って突っ込む
        b.Rect = new Rectangle(b.Rect.X, b.Rect.Y + 10, b.Rect.Width, b.Rect.Height);
        Debug.WriteLine(b.Rect.Y); // 50
        
        // こうやって関数にしても正しく動いたから
        // 関数にRectangleを渡したら参照渡しになる? よくわからん
        this.shiftRectangleY(b.Rect, 10);
        Debug.WriteLine(b.Rect.Y); // 50
    }
    private void shiftRectangleY(Rectangle r, int shiftY)
    {
        r = new Rectangle(r.X, r.Y + shiftY, r.Width, r.Height);
    }
}

特殊な木構造の描画[2]: 親ノードの描画領域の調整

[画像04]のように、子ノードがいい感じの位置に描画されても親ノードが上の方にあると別の枝と線が被ってしまう。
これを避けるために、パス1に以下の変更を加える。

具体的にはRectangleを弄ることで実装できるので、さっきは唐突にクラスと構造体の話をぶち込んだ。
ここまでやると下のような出力が得られるようになり、ひとまず木としての体裁が整う。

特殊な木構造の仮出力2 画像07: 特殊な木構造の仮出力2

出力画像が大きくなってきたので、pictureBoxに手のひらツールを実装しておくとプレビューが楽になる。

木構造の改良・描画方法の改良

[画像00]では、枝が上側に生えている部分が存在する。これも実現しておきたい。
そこでノードの属性を以下の3種類に分類し直す。

また、子ノードは必ずしも親と同じかそれより下になるように律儀に並べなくてもいいのでその辺は適当に調整できるようにしたい。
すなわち線が被らない範囲でできるだけ無駄な空間を作らないようにノードを配置したい。
この辺を頑張って実装すると下のような出力が得られる。さっきの冗長な感じがだいぶマシになった。

特殊な木構造の仮出力3 画像08: 特殊な木構造の仮出力3

さらに[画像00]では枝を下側に生やして後継板を前身の真下に配置している部分も存在する(サジェスト汚染掲示板)。
そこでノードの属性を再び以下の4種類に分類し直す。

ネーミングセンスがみじんも感じられない振り分けだが仕方がない。本質とは関係ないので無視。
情報量も若干増やして線の太さとかも調整して図を作り直したものが以下。

特殊な木構造の仮出力4 画像09: 特殊な木構造の仮出力4

ベクタ形式データの出力

(注意: この節は無駄に長い割に大したことは書いてないので流した方が幸せになれるかも。その1~その6は結局ダメだった方法。)

ここまでの画像出力は以下のようにして行ってきた。このようにして出力されたPNGは透過画像になっている。
[画像04]は縮小処理を行っていない生画像なので透過PNGになっている。

// デザインタブで作ったPictureBoxのImageに書き込む
// PictureBoxのGraphicsに書き込むとスクロールなどの操作で消えてしまう
Graphics g = Graphics.FromImage(this.pictureBox1.Image);
// 木構造の描画を行う
this.drawTree(g);
// 図を出力する
this.pictureBox1.Image.Save("test.png", ImageFormat.Png);

ここまでなら簡単にできるが、作図は機械的に行っているのでラスタイメージにこだわる必要はない。
せっかくならベクタイメージとして出力しておきたい。PDFあたりのフォーマットで出力できたらうれしい。

その1: PDF生成ライブラリ「PDFsharp」

まずはC#だけでPDFを作ってみようと考えてPDFsharpを使ってみた。NuGetから簡単にインストールできた。
最初にお試し用に簡単なPDFを出力するコードを書いてみた。

// ドキュメントの作成と描画用グラフィックスの取得
PdfDocument document = new PdfDocument();
PdfPage page = document.AddPage();
XGraphics gfx = XGraphics.FromPdfPage(page);
// フォントの指定
XPdfFontOptions options = new XPdfFontOptions(PdfFontEncoding.Unicode, PdfFontEmbedding.Always);
XFont font = new XFont("MS UI Gothic", 12, XFontStyle.Regular, options);
// グラフィックスへの書き込みとファイルへの出力
gfx.DrawString("唐澤貴洋掲示板", font, XBrushes.Black, new XRect(0, 0, page.Width, page.Height), XStringFormats.Center);
document.Save("test.pdf");

ところが、いきなりエラーで止まってしまった。追加情報曰く「Error while parsing an OpenType font.」だそうだ。
OpenTypeフォントの処理でエラーが起こっている、ということらしい。
とりあえずTrueTypeの適当なフォントを指定し直してもう一回動かしてみたら今度は出力できた。

PDFsharpによるPDFの出力 画像10: PDFsharpによるPDFの出力

フォントについては色々と試したが、どうやらTrueTypeじゃないとダメっぽい。
インストールされているフォントの8割くらいはOpenTypeなので、ほとんどのフォントは使えないということになる。
という訳で、一旦このライブラリでの出力は諦めて方向性を変える。

その2: プリンタ「PageManager PDF Writer」経由の出力

ドキュメントをPDF化するときにプリンタドライバを経由させるというのは結構よく使われる手法である。
ありきたりではあるけどプリンタ経由でPDFつくればいいじゃん。ってな訳ですぐに思いついたので適当に書いてみた。

// 先にメニューはデザインしておく
private void 印刷プレビューToolStripMenuItem_Click(object sender, EventArgs e)
{
    this.printDocument1.DefaultPageSettings.PaperSize = new System.Drawing.Printing.PaperSize("Custom", this.pictureBox1.Width, this.pictureBox1.Height + 200);
    this.printPreviewDialog1.ShowDialog();
}
private void printDocument1_PrintPage(object sender, System.Drawing.Printing.PrintPageEventArgs e)
{
    // 描画先を変えるだけでおk
    Graphics g = e.Graphics;
    this.drawTree(g);
    e.HasMorePages = false;
}

これだけだと既定のプリンタで印刷されてしまうので、コードを書き足すか既定のプリンタを変える必要がある。
印刷プレビューで拡大してみたらいい感じ。

印刷プレビューで500%表示を行ったところ 画像11: 印刷プレビューで500%表示を行ったところ

ここからどうPDFに持っていくか。使えるプリンターはフリーでたくさん出回っている。
そんな中、どういう訳か最初からPCに入っていた「PageManager PDF Writer」でやってみた。
(Windowsにはデフォで入っているらしいけど正確な情報ではないのでよくわからん。)
その結果出力されたPDFの一部を拡大したものが以下。

PageManager PDF Writer の出力 画像12: PageManager PDF Writer の出力

どうやらスケールを設定した文字はラスタイメージに置換されてしまうらしい。しかもかなり粗目。
スケールを設定しなかった文字は文字として残っているがフォントの指定がぶっ飛んでいる(上の画像で選択されている部分)。
色々とガバガバで問題が多すぎるのでこの方法は却下。

その3: プリンタ「Microsoft XPS Document Writer」経由の出力

仕方がないので「Microsoft XPS Document Writer」でOXPSとして出力する路線に変更。
こっちはOSに最初からついてる。MS版PDF的なものが出力できる。
早速出力して拡大してみたところ、スケールを設定した文字列は若干表示が荒いように感じられる。
さっきのPDFと同じく、スケールを設定したところは文字情報が削られてラスタライズされているようだ。
これだけではプレビューが荒いだけなのかファイルの質が悪いのかイマイチわからない。
「XPSビューアー」は拡大できる縮尺に限界がある。下の画像は最大まで拡大した状態における表示である。

Microsoft XPS Document Writer の出力 画像13: Microsoft XPS Document Writer の出力

どうせ欲しいのはPDFなので、こいつはPDFに変換しておきたい。そのPDFでファイルの質を確認すればよい。

その4: Webサービスによる OXPS→PDF の変換

ということで、さっきのOXPSをもう一回PDFに変換してみた。ちょっと抵抗があるけどオンラインのサービスで。
これ系のサービスはいくつかあるので何種類か試してみた。

  1. Convert XPS to PDF Online Free (HTTPS)
    https://xps2pdf.co.uk
  2. XPS to PDF - Convert XPS and OXPS files to PDF
    http://xpstopdf.com
  3. Convert XPS to PDF online & free
    https://online2pdf.com/convert-xps-to-pdf

各種Webサービスによる OXPS→PDF の変換結果 画像14: 各種Webサービスによる OXPS→PDF の変換結果

これらの結果から、OXPSの時点で文字のスケールを弄ったところが荒くなってしまっていたのではないかと考えられる。
この路線はこれで限界のようなので別の方法でやってみる。

その5: プリンタ「PDF24 PDF」・「Bullzip PDF Printer」経由の出力

仕方がないので新しくプリンタドライバをインストール。
とりあえず「PDF24 PDF」と「Bullzip PDF Printer」でやってみた。
その結果が以下。

「PDF24 PDF」(上)、「Bullzip PDF Printer」(下)の出力 画像15: 「PDF24 PDF」(上)、「Bullzip PDF Printer」(下)の出力

どちらともほとんど同じ結果となった。このままでは使えない。
ファイルサイズは「PDF24 PDF」だと239KB、「Bullzip PDF Printer」だと265KBだったのでどこかが微妙に違うようだ。
この結果からわかるように、これらのプリンタを通した時点である程度の劣化は避けられないらしい。

仕方がないのでちょっと方向性を変えてみた。どうせ画像になるなら解像度を思いっきりあげればいいんじゃね?と。
ところがこれが中々大変だった。というか結論を言ってしまうと解決してない。

まず、「printDocument1」の解像度を変更したいというとき、弄れそうなものが2種類ある。
また、printDocument1_PrintPageイベントハンドラ内で弄れそうなものが1種類ある。
これらの説明書きにはどれも同じく「ページのプリンター解像度を取得または設定します。」と書いてある。
まあどれも型は同じでSystem.Drawing.Printing.PrinterResolutionだからなあ。

どれを弄ったらいいのか全く分からん。しかも検索してもそれらしい資料がほとんど出てこない。
コンストラクタやメソッドでは解像度が指定できないらしい。
さらに明示されてはいないがPrinterResolution.XPrinterResolution.Yは読み取り専用かのような説明書きが。

また、Graphicsについても弄れそうなものがいくつかある。質を上げたいときは以下のように書くことがあるようだ。

graphics.CompositingQuality = System.Drawing.Drawing2D.CompositingQuality.HighQuality;
graphics.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.Bicubic;
graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.HighQuality;
graphics.CompositingQuality = System.Drawing.Drawing2D.CompositingQuality.HighQuality;

とりあえず一番最初のやつを弄ろうとして下のような感じで書いてみた。訳あってプレビューは通していない。

// 用紙サイズの指定(作成する図に合わせる)
this.printDialog1.PrinterSettings.DefaultPageSettings.PaperSize = new System.Drawing.Printing.PaperSize("Custom", this.pictureBox1.Width, this.pictureBox1.Height + 200);
// 解像度の指定
this.printDialog1.PrinterSettings.DefaultPageSettings.PrinterResolution.Kind = System.Drawing.Printing.PrinterResolutionKind.Custom;
this.printDialog1.PrinterSettings.DefaultPageSettings.PrinterResolution.X = 4000;
this.printDialog1.PrinterSettings.DefaultPageSettings.PrinterResolution.Y = 4000;
// printDialog1とprintDocument1の関連付けは先にデザイナでしておく
// ダイアログではプリンタとして「Bullzip PDF Printer」を指定する
if (this.printDialog1.ShowDialog() == DialogResult.OK)
{
    this.printDocument1.Print();
}

このコードを走らせたら下の画像のような感じで出力された。画像は1200%表示したものである。
どんどん拡大して6400%表示にするとようやく粗さが分かってくる。それくらいには精度の高い表示になっている。

解像度を指定して出力したPDFの一部(1200%表示) 画像16: 解像度を指定して出力したPDFの一部(1200%表示)

これだけだといい感じに見えるが、実は用紙サイズの指定が反映されていないので下の図のような感じになってしまっている。

解像度を指定して出力したPDFの全体 画像17: 解像度を指定して出力したPDFの全体

さっきプレビューからプリンタに送ったときは用紙サイズの指定が反映されたので、今度はプレビュー側から攻めてみた。
でも今度は解像度の変更が利かない。どっちか弄ろうとすると他方が弄れない。
書く位置や順番を弄りまくって3時間ぐらい頑張ったけど駄目だった。色々書きたいけど長くなるしめんどいからやめておく。
描画する内容を縮小すればいいじゃんって話だけど十数か所も書き換えるの面倒だし所詮ラスタイメージなのは変わらないので却下。

その6: CreateFont API によるフォントの調整

今まで文字幅の調整にScaleTransformを使ってきた。これのおかげで色々と苦労をしている。
逆にスケール調整をしていない所はそのまま文字情報として出力されるので劣化の心配はない。
それなら文字幅を調整したフォントをこしらえてやればいいじゃないか、という風に考えた。
幸いなことに、検索したらそれっぽいことが少し出てきた。

これらを参考に以下のようなコードを書いてみた。

// クラス内の最初らへんに書いておく
[DllImport("gdi32.dll")]
static extern IntPtr CreateFont(int nHeight, int nWidth, int nEscapement,
   int nOrientation, int fnWeight, uint fdwItalic, uint fdwUnderline, uint
   fdwStrikeOut, uint fdwCharSet, uint fdwOutputPrecision, uint
   fdwClipPrecision, uint fdwQuality, uint fdwPitchAndFamily, string lpszFace);

private void 適当な描画関数(Graphics g){
    // 描画に使う情報
    Brush b = new SolidBrush(board.color);
    Rectangle r = new Rectangle(20, 40, 60, 80);
    string s = "描画したい長めの文字列をここに入れる";

    // 論理フォントの作成
    IntPtr hFont = CreateFont(
          20,  // フォントの高さ
          10,  // 平均文字幅
          0,   // 文字送り方向の角度
          0,   // ベースラインの角度
          400, // フォントの太さ
          0,   // 斜体にするかどうか
          0,   // 下線を付けるかどうか
          0,   // 取り消し線を付けるかどうか
          1,   // 文字セットの識別子
          0,   // 出力精度
          0,   // クリッピング精度
          0,   // 出力品質
          0,   // ピッチとファミリ
          "MS UI Gothic" // フォント名
        );
    Font labelFont = Font.FromHfont(hFont);
    // 描画処理
    g.DrawString(s, labelFont, b, r);
}

このコードでは文字幅を高さの半分にしてみた。
しかし出力では普通の大きさの文字で描画されていた。高さは変更が反映されるが、幅は高さに合わせて決まってしまうらしい。
つまり、文字の大きさは弄れるけど縦横比は依然弄れないまま、ということになる。
どうにかならないかと検索しまくっているうちに、「.NETを使っている時点で無理」とする説が浮上。

結局のところ.NETではScaleTransformを使うしかないようである。

その7: メタデータの出力とPDFの作成

自分の中で「ScaleTransformを使った時点で内部では既にラスタライズされてしまっているのではないか」という疑惑が濃厚に。
そんな状況の中、こんな記事が。

どうやらGraphics上ではまだラスタライズされていない、つまりベクタイメージのまま残っているようである。
だったらそれをそのまま出力しておけばいいじゃん。ってなわけで検索したらすぐに出てきた。

wmfとしてファイルに書き出すだけなら以下のような感じのコードで出力可能である。

Metafile mf;
using (Graphics g = CreateGraphics())
{
    IntPtr ipHdc = g.GetHdc();
    mf = new Metafile("metafiletest.wmf", ipHdc, EmfType.EmfPlusDual);
    g.ReleaseHdc();
}
using (Graphics g = Graphics.FromImage(mf))
{
    // 今までと同じ描画処理
    this.drawTree(g);
}

ファイル内容を確認するために色々なソフトウェアで開いてみたところ以下のような感じに。

メタファイルを各種ソフトウェアで開いた結果 画像18: メタファイルを各種ソフトウェアで開いた結果

どうやらwmfをPDFみたくグリグリ操作して閲覧できるソフトはなさそう。
手間だけれどWordに手動で貼り付けてPDFとしてエクスポートしてみたところ、きちんとベクタイメージとしてデータを保持していた。
しかしWordを経由したせいで文字は全てパスに変換されてしまったようである(後でPDFをInkscapeで開いたら文字が崩れなかった)。
環境に依存せず正常に閲覧できる代わりに文字選択ができなくなってしまった。まあいいや。

Word経由でメタファイルから作成したPDFの一部(2400%表示) 画像19: Word経由でメタファイルから作成したPDFの一部(2400%表示)

その8: C#単体でのPDF出力

一旦書き出したものを手動でWordに貼り付けてエクスポートとか、面倒だし何かカッコ悪い。
どうにかして.NET単体でできないかと、以下のような方法を考えてみた。

  1. 一時ファイルとしてメタファイル(*.wmf)を書き出す
  2. さっきのメタファイルを張り付けたワード文書を作成する(これを一時ファイルとして書き出す必要はない)
  3. さっきのワード文書をPDFとしてエクスポートする
  4. 一時ファイルを削除する

本当はメタファイルのバイナリをメモリから直接流したかったが、そういった機能を提供する関数はWord側には存在しないようなので一時ファイルで妥協しておいた。
以下のページたちを参考にコードを書いてみたらあっさり動いた。

// この2つは追加しておかないといけない
// 別途「Microsoft Word 15.0 Object Library」を参照に追加しておく
using Word = Microsoft.Office.Interop.Word;
using System.Reflection;

/// <summary>
/// ToolStripMenuから"PDF出力"を選択したときに実行される。
/// ToolStripMenuは先にデザインしておく。
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void pDF出力ToolStripMenuItem_Click(object sender, EventArgs e)
{
    if (this.saveFileDialogPDF.ShowDialog() != DialogResult.OK)
    {
        return;
    }
    // ファイルが存在する場合は上書きできるか確認する
    if (File.Exists(this.saveFileDialogPDF.FileName))
    {
        if (IsFileLocked(this.saveFileDialogPDF.FileName))
        {
            // ファイルが他のタスクによって開かれているので上書きできない
            writeLog("ファイルが他のタスクによって開かれているので上書きできません\r\n");
            return;
        }
    }
    
    FileAttributes attr = File.GetAttributes(this.saveFileDialogPDF.FileName);
    if ((attr & FileAttributes.ReadOnly) == FileAttributes.ReadOnly)
    {
        // 読み取り専用なので上書きできない
        return;
    }
    
    // 一時ファイルの名前
    // Wordでは引数に相対パスが使えないようなので絶対パスで表現する
    string metaFileName = System.Environment.CurrentDirectory + @"\temp" + DateTime.Now.ToBinary().ToString() + ".wmf";

    // メタファイル(一時ファイル)の生成
    Metafile mf;
    using (Graphics g = CreateGraphics())
    {
        IntPtr ipHdc = g.GetHdc();
        mf = new Metafile(metaFileName, ipHdc, EmfType.EmfPlusDual);
        g.ReleaseHdc();
    }

    using (Graphics g = Graphics.FromImage(mf))
    {
        // いつもの描画処理
        this.drawTree(g);
    }
    mf.Dispose(); // これやっておかないと後で一時ファイルを削除できない

    await Task.Run(() =>
    {
        // Wordを経由してPDFを作成
        object oMissing = System.Reflection.Missing.Value;
        object oEndOfDoc = "\\endofdoc"; /* \endofdoc is a predefined bookmark */

        Word._Application oWordApp = null;
        Word._Document oWordDoc = null;
        try
        {
            // Wordを起動して新規ドキュメントを作成する
            Invoke(new WriteLogDelegate(writeLog), "Wordを起動して新規ドキュメントを作成しています");
            oWordApp = new Word.Application();
            oWordApp.Visible = false; // ここはお好みで。ドキュメントがガシガシ出来上がっていくところが見たければ""
            oWordDoc = oWordApp.Documents.Add(ref oMissing, ref oMissing,
                ref oMissing, ref oMissing);

            // 余白と右寄せの設定
            Invoke(new WriteLogDelegate(writeLog), "余白と右寄せの設定をしています");
            oWordDoc.PageSetup.TopMargin = MarginPDF; // UIスレッド側で先に設定しておいた値
            oWordDoc.PageSetup.BottomMargin = MarginPDF;
            oWordDoc.PageSetup.LeftMargin = MarginPDF;
            oWordDoc.PageSetup.RightMargin = MarginPDF;
            oWordDoc.Paragraphs.Alignment = Word.WdParagraphAlignment.wdAlignParagraphRight;

            // 画像の挿入
            Invoke(new WriteLogDelegate(writeLog), "画像を挿入しています");
            Word.Range wrdRng = oWordDoc.Bookmarks.get_Item(ref oEndOfDoc).Range;
            Word.InlineShape oShape = wrdRng.InlineShapes.AddPicture(metaFileName);

            // テキストの挿入
            Invoke(new WriteLogDelegate(writeLog), "テキストを挿入しています");
            wrdRng = oWordDoc.Bookmarks.get_Item(ref oEndOfDoc).Range;
            wrdRng.InsertParagraphAfter();
            wrdRng.InsertAfter("連絡先: http://twitter.com/su_te_ak");

            // PDFとしてエクスポート
            // 他のタスクで使用中のファイルだと'System.Runtime.InteropServices.COMException'が投げられて上書きでき
            Invoke(new WriteLogDelegate(writeLog), "ドキュメントをPDFとしてエクスポートしています");
            oWordDoc.ExportAsFixedFormat(saveFileDialogPDF.FileName, Word.WdExportFormat.wdExportFormatPDF);
            Invoke(new WriteLogDelegate(writeLog), "PDFの出力に成功しました");
        }
        catch (Exception ex)
        {
            //エクスポートに失敗
            Invoke(new WriteLogDelegate(writeLog), "PDFの出力に失敗しました: " + ex.Message);
        }
        finally
        {
            Invoke(new WriteLogDelegate(writeLog), "文書を閉じて一時ファイルを削除しています");
            {
                // 変更内容を保存したりユーザーにメッセージを表示したりせずに文書を閉じる
                if (oWordDoc != null)
                {
                    try
                    {
                        oWordDoc.Close(Word.WdSaveOptions.wdDoNotSaveChanges);
                    }
                    catch
                    {
                    }
                }
                if (oWordApp != null)
                {
                    try
                    {
                        oWordApp.Quit(Word.WdSaveOptions.wdDoNotSaveChanges);
                    }
                    catch
                    {
                    }
                }
                // COMオブジェクトを解放する
                Form1.FinalReleaseComObjects(oWordDoc, oWordApp);
                // 一時ファイルの削除
                File.Delete(metaFileName);
            }
        }
    });
    writeLog("出力操作が終了しました");
}
public static void FinalReleaseComObjects(params object[] objects)
{
    foreach (object o in objects)
    {
        try
        {
            if (o == null)
                continue;
            if (Marshal.IsComObject(o) == false)
                continue;
            Marshal.FinalReleaseComObject(o);
        }
        catch (Exception)
        {
        }
    }
}
private bool IsFileLocked(string path)
{
    FileStream stream = null;

    try
    {
        stream = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
    }
    catch
    {
        //'System.IO.IOException'が投げられる
        return true;
    }
    finally
    {
        if (stream != null)
        {
            stream.Close();
        }
    }

    return false;
}
/// <summary>
/// サブスレッドからUIに進捗を報告するためのデリゲート
/// </summary>
/// <param name="s"></param>
delegate void WriteLogDelegate(string s);

これでめでたくC#単体で綺麗なPDFを出力できるようになった。正直疲れた。
この処理は時間が掛かるので経過を逐次報告するようにしている。吐き出されるログは以下のような感じ。
見てもらえばわかるがPDFを作るだけで25秒くらい掛かっている。そのほとんどはWordの起動に掛かった時間である。

[2015/09/27 16:03:24] Wordを起動して新規ドキュメントを作成しています
[2015/09/27 16:03:42] 余白と右寄せの設定をしています
[2015/09/27 16:03:43] 画像を挿入しています
[2015/09/27 16:03:45] テキストを挿入しています
[2015/09/27 16:03:45] ドキュメントをPDFとしてエクスポートしています
[2015/09/27 16:03:46] PDFの出力に成功しました
[2015/09/27 16:03:46] 文書を閉じて一時ファイルを削除しています
[2015/09/27 16:03:48] 出力操作が終了しました

こういう時間のかかる処理はバックグラウンドに持っていくもんだけど、面倒なのでいつも後回しにしがち。
Async使うならUIに通知したいときはInvokeを通してやる。
別に一々UIスレッドに制御を戻してもいいけど、スレッド1つでどうにかなるのでスレッドを乱用するような真似はしたくない。
でないとUIスレッドに何回も戻って途中経過を逐一報告したいなんていう今回のような場合にはTask.Runを連呼することになるので。
でも正直Invokeは使い慣れていないのでBackgroundWorkerの方が楽に書けそうでどちらを使うか迷った。

また当然ではあるがこのコードはWordがインストールされていないと動かないので少し環境を選ぶ。
.NETを使っている時点でそんなことはどうでもいい訳だが、世の中にはOpenOfficeとかを愛用する人もいるので一応。

C#で自動的に作成したPDF 画像20: C#で自動的に作成したPDF

線の描画方法の改良

上の画像を見て気付いた人もいるかもしれないが、線の描画方法はかなり適当である。
[画像20]において線の太さが親に近いほど太くなっているように見えるのはこの適当な描画方法が原因である。
メタファイルは描画結果でなく描画方法を記録するので、「線の上に線を重ねた」という過程も織り込まれてしまう。
そのメタファイルを張り付けただけのPDFでもこれは同じである。
この現象は特に縮小表示したときに顕著になり、拡大表示すると目立たなくなる。
また最初からラスタイメージとして出力された画像では線がピクセルレベルでぴったりと重なっているのでこの現象は起きない。

これまで用いてきた線の描画方法 画像21: これまで用いてきた線の描画方法

この問題を解決するためには、複数の線を重ねて描画する代わりに多角形を描画すればよさそう。
ちょうどFillPolygonなんてのがあるから、これを使えば簡単に実装できそう。
ここで気にしないといけないのが、線を描画するとき指定した座標と線の太さの関係である。
メタファイルに出力すると描画時に指定した座標はその精度の範囲内では数学的な意味を失わない厳密な値として記録される([画像19]左端などを参照)。
ところがラスタライズされると下図の通り部分的に差異が生じる。
塗り潰し図形の描画時に指定された座標がピクセルのど真ん中だと、そのピクセルは条件により塗り潰されたり塗り潰されなかったりする。

ラスタライズされたときの座標の関係 画像22: ラスタライズされたときの座標の関係

どうやらビットマップになる過程で線の端が塗り潰されたり塗り潰されなかったりする模様。
線の上端・左端は塗り潰され、下端・右端は塗り潰されないようである。
でもって、線の場合は線の始点・終点・幅の3つの情報を指定するが、これを多角形で表現するためには幅のところで少々計算が必要になる。

線の描画方法を改良した後の表示 画像23: 線の描画方法を改良した後の表示

タイトル・凡例・背景画像の挿入

木構造についてはこれくらいにしておいて、系譜がそれらしくなるように色々と機能を追加しておいた。
タイトルやら凡例を描画してみたりとか。大して難しくもないのでコードとかは省略。
また、類似画像として以下のような画像も出回っているので背景も描画しておきたい。

さらに幸いなことに背景に使えそうなsvgが転がっている。優しい誰かがトレースしてくれたようだ。
PDFにラスタイメージをそのまま貼るなんてのは格好悪いし、ラスタイメージを自分でトレースするのはさすがに面倒が過ぎるので、
こういう素材があらかじめ用意してあるというのはかなり嬉しい。

ただし.NETではSVGを直接読み込むことができないので、先にInkscapeで読み込んでEMFとして保存し直しておく必要がある。
可能ならWMFに変換しておいてもよいが、こちらで試したところ変換用スクリプトのエラーで正しく出力されなかった。
別にEMFで問題ないので特に理由がなければEMFを用いるべきである。
「誹謗中傷対策.svg」をEMFに変換して背景画像として挿入すると下の画像のような系譜画像が完成する。

タイトル・凡例・背景画像を挿入した結果(40%表示) 画像24: タイトル・凡例・背景画像を挿入した結果(40%表示)

大した話ではないが、挿入する画像は縦横比が系図と異なるので描画位置や大きさは自分で計算してあげないといけない。
また背景画像をそのまま描画すると色の関係で背景が目立って仕方がないので、背景が薄くなるように少々工夫する必要もある。

まとめ

試行錯誤の末、以下のような系図が作成できた。
プリンタ関連やメタファイル関係は初めて弄ったので間違った記述もあったかもしれないが、とりあえず目的物が完成ので良しとする。

一応記しておくが、このサーバの設定のせいで専ブラのUAは弾かれてしまうらしい。
少なくとも我がV2Cは弾かれる。他は検証していないのでわからない。

補足

系図を作成する過程で気付いたこと・その他報告。

追記1 (2015/09/28-1)

系図を修正。寄せられた意見に関する対応は以下の通り。

系図の修正版は以下から利用可能。

追記2 (2015/09/28-2)

系図を修正。寄せられた意見に関する対応は以下の通り。

系図の修正版は以下から利用可能。


管理人Twitter: @su_te_ak/◆mmft4k9vgtL6
要望等はTwitterへ

Home -> 雑用 -> 雑用メモ -> [5.14 [C#]特殊な木構造のデータ管理と図への描画]

ここ以降は鯖が勝手に付加するやつです