技術記事

Excel は 3.14159 を 3 14/99 ではなく 3 1/7 で出す

3.14159 に分数マスク # ??/?? を当てると、Excel は 3 1/7 を出します。3 14/99 のほうが 7 倍以上近いというのに、です。だから最近分数を探す Delphi エクスポートは、ユーザーが決して目にしない値を印字します。理由は、Excel が連分数の収束値しか受け入れないことにあり、HotXLS の TXLSXCell.FormattedText は同じ 3 1/7 を、パディング空白も込みで返します

Excel は連分数の収束値で止まる

分数を近似するマスクはどれも、Excel に同じ仕事を与えます。整数の後の部分について分子と分母を見つけること、そして分母の上限はスラッシュの後の桁スロット数です。Excel は値を連分数として展開し、収束値だけを保持することでこれを遂行します。収束値とは、その展開を各項の後で打ち切って得られる分数のことです。0.14159 の展開は [0; 7, 15, 1, ...] で始まり、1/7、次いで 15/106、16/113 を生みます。整数の 3 は何も変えません。3 を付けた 3.14159 は、円周率の近似表に必ず載っているのと同じ 22/7、333/106、355/113 をたどります。106 がすでに 2 桁の分母をあふれるので、Excel は 1/7 を保持します。より近い 14/99 は半収束値、隣り合う 2 つの収束値の間に座る中間分数の 1 つです。Excel はそこへ足を踏み入れません。カスタムコードなしでもこれに遭遇します。「セルの書式設定」ダイアログの Up to one digit と Up to two digits の種類は、ECMA-376 Part 1 §18.8.30 の定義済み表示形式リストにおける組み込み書式 ID 12 と 13、# ?/? と # ??/?? なのです

3.14159 の連分数収束値の数直線。ズーム軸上の 22/7、333/106、355/113、そしてどの収束値を Excel が保持するかを決める分数マスクの分母上限
展開の打ち切りは毎回 3.14159 に近づき、スラッシュの後の桁数が上限を生き残る収束値を決めます

この隔たりは円周率系の入力に限りません。最良近似、たいていは Stern-Brocot の歩みか全許容分母の走査ですが、どの分数が最も近いかを問い、収束値のループは、分母がまだ収まるうちに展開が最初にどの分数へ届くかを問います。普通の 2 桁小数の入力でも 2 つのルールは、問題になる頻度で食い違い、下のコードの 0.27 はそのような値の 1 つです。コードが PDF の請求書や HTML レポート、あとでワークブックと突き合わせられる CSV に分数を書くなら、精度では負けていても、そこでは Excel のルールを写してください。紙の上で 3 14/99 を見て Excel で 3 1/7 を見た読者は、エクスポートのほうが精密だと結論しません。どちらかが壊れていると結論するのです。壊れているのは Excel ではないでしょう

0.27 のズーム数直線。Excel が表示する収束値 10/37 に対し、最近分数の走査はより近い 17/63 を出す。食い違いと一致のサンプル付き
普通の 2 桁小数の値では、収束値ループと最近分数走査は問題になる頻度で食い違うので、エクスポートは Excel のルールを写すべきです

ループ自体は短いものです。スラッシュの後の桁プレースホルダを数えます。? か # か 0 かで、1 個、2 個、3 個に対する上限は 9、99、999 です。# ??/?? のような帯分数マスクは整数を切り分けて小数部分だけを展開し、?/? のような仮分数マスクは値全体を展開します。だから 3.14159 が ?/? の下で 22/7 として出てくるのです。各ステップは走行値 B の整数部 A を取り、次の収束値へ折りたたみます。剰余 B − A が 5e-8 を下回ったらループは止まるので、単純分数に浮動小数点ノイズが乗っても、その分数に着地します。ステップが分母を上限の向こうへ押しやると、Excel は前の収束値へ後退し、その後退は 1/1 に着地することもあります。帯分数マスクはこれを整数へ運ぶので、# ?/? の下の 12.9999 は 13 を示します。以下では、ループを素の Delphi で書き出し、総当たりの最近分数検索を隣に置きました。自分の数値で 2 つがどこで分かれるか見られます

uses
  SysUtils;

type
  TFraction = record
    Num, Den: Int64;
  end;

// Excel の # ?/?、# ??/??、# ???/??? マスクの背後にある収束値ルール。
// X は非負:帯分数マスクなら小数部分、仮分数マスクなら値全体。
// MaxDen は 9、99、999 のどれか。
function ExcelFraction(X: Double; MaxDen: Integer): TFraction;
var
  B, R: Double;
  A, P, Q, P1, Q1, P2, Q2: Int64;
begin
  B := X;
  P2 := 0; Q2 := 1;      // 収束値 k-2
  P1 := 1; Q1 := 0;      // 収束値 k-1
  P := 0;  Q := 1;
  while Q1 < MaxDen do
  begin
    A := Trunc(B);
    P := A * P1 + P2;
    Q := A * Q1 + Q2;
    R := B - A;
    if R < 5E-8 then
      Break;               // 剰余は 0 とみなす
    B := 1 / R;
    P2 := P1; Q2 := Q1;
    P1 := P;  Q1 := Q;
  end;
  if Q > MaxDen then       // 上限を飛び越えた:収束値を 1 つ戻す
    if Q1 > MaxDen then
    begin
      P := P2; Q := Q2;
    end
    else
    begin
      P := P1; Q := Q1;
    end;
  Result.Num := P;
  Result.Den := Q;
end;

// 「最良近似」ルーチンが返すもの:D <= MaxDen の中で最も近い N/D
function NearestFraction(X: Double; MaxDen: Integer): TFraction;
var
  D: Integer;
  N: Int64;
  Err, BestErr: Double;
begin
  Result.Num := Round(X);
  Result.Den := 1;
  BestErr := Abs(X - Result.Num);
  for D := 2 to MaxDen do
  begin
    N := Round(X * D);
    Err := Abs(X - N / D);
    if Err < BestErr then  // 厳密:同点では約分形が勝つ
    begin
      BestErr := Err;
      Result.Num := N;
      Result.Den := D;
    end;
  end;
end;

procedure CompareFractionRules;
const
  Samples: array[0..3] of Double = (0.14159, 0.99, 0.27, 0.5);
var
  I: Integer;
  E, N: TFraction;
begin
  for I := Low(Samples) to High(Samples) do
  begin
    E := ExcelFraction(Samples[I], 99);
    N := NearestFraction(Samples[I], 99);
    Writeln(Format('%-8g Excel %d/%d   nearest %d/%d',
      [Samples[I], E.Num, E.Den, N.Num, N.Den]));
  end;
end;

// 期待値:
// 0.14159  Excel 1/7   nearest 14/99
// 0.99     Excel 98/99   nearest 98/99
// 0.27     Excel 10/37   nearest 17/63
// 0.5      Excel 1/2   nearest 1/2

0.27 の行がリストにいるのは、普通の 2 桁小数の入力も食い違うからです。10/37 対 17/63、値には何の風変わりさもありません。ルールがどれほど頻繁に分かれるかはデータ次第です。0.5 や 0.375 のような真の単純分数はどちらの道でも同じ結果になり、実測値や計算比率はよほど頼りになりません。Trunc が Floor の代役を務めるのは、入力が決して負にならないからです。Excel は符号を分数の外で処理します。あなたのコードもそうすべきです。そして引数は、セルが実際に保持する Double でなければなりません。理由は次の節が具体化します

2 桁分数で 0.99 はどうなるのか

Excel は # ??/?? の下の 0.99 を ·98/99 と示します(本記事の · はすべて半角空白 1 文字の意味です)。原因はバイナリ浮動小数点です。リテラル 0.99 に正確な Double は存在せず、格納値はそのほんの少し下に座っています。それで 1/(1/0.99 − 1) は 99 の代わりにおよそ 98.9999999999994 と評価され、展開の第 2 項は 98 になり、収束値 98/99 が上限の下に収まります。同じ展開を正確な 10 進で行うと [0; 1, 99] が得られます。この 99 が分母を 100 へ押し上げ、ループは 1/1 に後退し、セルは 1······ と読めたはずのものになります。ここでは、Excel より丁寧であることがバグなのです。金額を Currency や TBcd で保持するレポート生成器や、データベースからの 10 進文字列を再パースして正確な算術で展開するものは、Excel が 98/99 と印字するところに整数の 1 を印字します。フォーマッタには格納された Double を与えてください。中間値はすべて浮動小数点で保持します。分数は表示の問題のままでもあります。数式がまだ 0.99 を見ており、HotXLS は表示桁で丸めをオンにしたワークブックでも、分数セクションを全精度で保ちます

Excel の分数マスク # ??/?? における 0.99 の 2 経路図。格納 Double は 98/99 に展開し、正確な 10 進は上限を飛び越えて整数 1 を表示する
格納 Double は 98/99 に着地し、正確な 10 進算術は 1/1 に後退します。ここで Excel より丁寧なのがバグです

Excel は分数部分をどう埋め整列するのか

Excel は分数を、10 進の列をレイアウトするのと同じやり方で並べます。分子はそのスロット内で右寄せ、分母は左寄せなので、スラッシュはどの行でも同じ位置に着地します。使われないスロットはそれぞれのプレースホルダに従って埋まります。? は空白を残し、0 は 0 を書き、# は何も書きません。だから 3.14159 が # ??/?? の下で 3··1/7· と読めるのです。1 つ目の空白はマスクからの区切り、1 つは 1 の前の空き分子スロット、1 つは 7 の後の空き分母スロットを埋めます。表は、レイアウトルールをカバーする入力群について Excel が表示するテクストを列挙します

書式コード値Excel の表示
# ?/?0.5·1/2
# ?/?4/31·1/3
# ??/??3.141593··1/7·
# ??/??0.99·98/99
# ?/?1212····
# ?/?00····
# ?/?12.999913····
# #/#1212
0 ?/?0.50·1/2
#,##0 ?/?1234.751,234·3/4
?/?3.1415922/7
?/?00/1
??/??1212/1·

整数こそ、手書きフォーマッタが Excel から最も遠くへ漂う場所です。分子に ? を使う帯分数マスクの下では、Excel は整数の桁をゼロ込みで保持し、区切りと分子、スラッシュ、分母をそれぞれ 1 個の空白に替えます。だから # ?/? の下の 12 は 12···· になり、# ??/100 の下では 7 個の空白が後ろに付きます。分子のプレースホルダを # に替えるとパディングは消えます。整数ゼロは逆向きに働きます。0.5 に対し # スロットは何も印字せず、区切りは空白としてだけ生き残ります。一方 0 ?/? は桁を強制し、仮分数マスクは分数をまったく落としません

そのパディングをどうするかは、テクストの行き先次第です。等幅テキストのレポートと右寄せの PDF 表セルでは保持してください。スラッシュを並べることがそこではすべての要点です。CSV と JSON では切り詰めます。末尾の空白 1 個が 12 を、もう数としてパースできない文字列に変えてしまう場所です。HTML も判断を要します。セルが white-space: pre を使わない限りブラウザは空白の連なりを潰すので、それを設定するか切り詰めるかのどちらかにし、一部の行だけずれる半分潰れたパディングは避けてください

# ?/8 のような固定分母

1 から 9 で始まる数字で書かれた分母は、上限ではなく固定値です。算術は完全に変わります。Excel は値に分母を掛け、最も近い整数の分子に丸め、分数を約分しません。0/8 の下では 0.375 は 3/8 を、0.64 は 5/8 を示します。0.64 × 8 は 5.12 だからです。標準の As sixteenths 種別は 0.5 を 8/16 と示します。図面を読む機械屋が期待するのはこれであり、結果をすべて約分するフォーマッタはそれを 1/2 に変えてしまいます。丸められた分子は分母に届くことがあります。すると帯分数マスクはそれを整数へ運びます。# ?/8 の下の 2.97 は 3···· と読めるのが期待値です。0.97 × 8 = 7.76 が満八に切り上がるからです。2 つのファミリーの選び方は、分母が読者に何を意味するかによります。固定分母は、ヤード・ポンド法の長さやドリルサイズ、8 分の 1 刻みの古い株価のように、固定ステップで語られるものすべてに合います。木材の寸法を読む者が 3/7 インチを望むことはありません。近似マスクは、特定の分母が意味を持たない比率や実測値に向きます

FormattedText で Delphi から Excel の分数テクストを読む

HotXLS なら、ループを自分で書く必要はめったにありません。TXLSXCell.FormattedText は格納値を、lxStandard.pas の TXLSFormater.GetDisplayValue でセルの NumberFormat を通して描画します。収束値の選択もパディングもリテラルテクストも、1 つの文字列にそろって届きます。下の手続きは値とマスクのペアをセルに割り当て、結果を括弧の間に印字します。末尾の空白が見えるままになるようにです。新しい TXLSXWorkbook はワークシートなしで始まるので、コードはセルに触れる前に Sheets.Add を呼びます。Sheets.Items と Cells[Row, Col] はどちらも 1 起点です

uses
  SysUtils, lxHandleX;

type
  TFractionCase = record
    Mask: string;
    Value: Double;
  end;

const
  Cases: array[0..8] of TFractionCase = (
    (Mask: '# ??/??';        Value: 3.14159),
    (Mask: '# ??/??';        Value: 0.99),
    (Mask: '# ?/?';          Value: 12),
    (Mask: '# ?/?';          Value: 12.9999),
    (Mask: '# #/#';          Value: 12),
    (Mask: '?/?';            Value: 3.14159),
    (Mask: '0/8';            Value: 0.64),
    (Mask: '"x"# ?/?" y"';   Value: -12),
    (Mask: '# ?/?;(# ?/?)';  Value: -1.5));

procedure ShowFractionText;
var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  Cell: TXLSXCell;
  I: Integer;
begin
  Book := TXLSXWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Fractions');   // 新しいワークブックにはまだシートがない
    for I := Low(Cases) to High(Cases) do
    begin
      Cell := Sheet.Cells[I + 1, 1];          // 行も列も 1 起点
      Cell.Value := Cases[I].Value;
      Cell.NumberFormat := Cases[I].Mask;
      Writeln(Format('%-16s %9g  [%s]',
        [Cases[I].Mask, Cases[I].Value, Cell.FormattedText]));
    end;
  finally
    Book.Free;
  end;
end;

// 期待値:
// # ??/??            3.14159  [3  1/7 ]
// # ??/??               0.99  [ 98/99]
// # ?/?                   12  [12    ]
// # ?/?              12.9999  [13    ]
// # #/#                   12  [12]
// ?/?                3.14159  [22/7]
// 0/8                   0.64  [5/8]
// "x"# ?/?" y"           -12  [-x12     y]
// # ?/?;(# ?/?)         -1.5  [(1 1/2)]

最後の 2 ケースは分数を取り巻くものを示します。引用テクストとバックスラッシュエスケープは素の文字として印字され、値全体のパディングは接尾辞の前に着地します。だから "x"# ?/?" y" は -12 を -x12·····y に変えます。負号は接頭辞込みで、すべての前に付きます。(# ?/?) のような明示的な負セクションが符号そのものを綴っている場合は別です。分数は、他のどの Excel 表示形式とも同じセクションと負号のルールに従います。値がセルではなくデータセットから来るなら、ワークブックを飛ばしてください。TXLSFormater には引数なしコンストラクタがあり、数値に対して FormattedText が呼ぶのが GetDisplayValue(Value, NumberFormat) です

uses
  SysUtils, lxStandard;

function FractionText(const AValue: Double; const AMask: WideString): WideString;
var
  Fmt: TXLSFormater;
begin
  Fmt := TXLSFormater.Create;
  try
    Result := Fmt.GetDisplayValue(AValue, AMask);
  finally
    Fmt.Free;
  end;
end;

// FractionText(0.5, '# ?/?')                期待値: ' 1/2'
// FractionText(1234.75, '#,##0 ?/?')        期待値: '1,234 3/4'
// Trim(FractionText(12, '# ?/?'))           期待値: '12'(CSV 向け)

Delphi コードが Excel の外で印字する分数はどれも、収束値ループから取るか、セル自身の Double を与えられた FormattedText から直接取るべきです。そうすれば紙の上の 3 1/7 がグリッドの中の 3 1/7 であり、HotXLS Delphi spreadsheet component があれば、そのテクストをループを一切書かずに手に入ります