技术文章

Excel 分数格式把 3.14159 显示成 3 1/7 而非 3 14/99

用分数掩码 # ??/?? 格式化 3.14159,Excel 显示 3 1/7,尽管 3 14/99 近了七倍还不止——于是搜索最近分数的 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.14159 走的还是每张圆周率近似表里都有的 22/7、333/106 与 355/113。106 已经塞不进两位分母,Excel 便留下 1/7。更近的 14/99 是半渐近分数,即落在相邻两个渐近分数之间的中间分数。Excel 从不光顾它们。不用自定义代码也会撞上:Format Cells 对话框的 Up to one digit 与 Up to two digits 类型就是内置格式 ID 12 和 13,即 ECMA-376 Part 1 §18.8.30 预定义数字格式清单里的 # ?/? 与 # ??/??

3.14159 连分数渐近分数的数轴:放大坐标轴上的 22/7、333/106 与 355/113,分数掩码的分母上限决定 Excel 保留哪个渐近分数
展开每截一段都更逼近 3.14159,斜杠后的位数决定哪个渐近分数在封顶下幸存

这个差距不限于类圆周率输入。最近分数(通常走 Stern-Brocot 游走,或扫一遍所有允许的分母)问的是哪个分数最近;渐近分数循环问的是分母还装得下时,展开先走到哪个分数。寻常两位小数输入上,两条规则分道扬镳的频率足以构成问题,下面代码里的 0.27 就是一例。如果你的代码把分数写进 PDF 发票、HTML 报表或要拿去和工作簿对账的 CSV,请照抄 Excel 的规则,哪怕它是较不准的那个。纸上看到 3 14/99、Excel 里看到 3 1/7 的读者不会得出“你的导出更精确”的结论,只会得出两者必有一个坏了的结论,而那个不会是 Excel

0.27 的放大数轴:Excel 显示的渐近分数 10/37 对比最近分数扫描给出的更近的 17/63,含分歧与一致的样本
寻常两位小数值上,渐近分数循环与最近分数扫描分岔的频率足以构成问题,导出应照抄 Excel 的规则

循环本身很短。数一数斜杠后的数字占位符——?、# 还是 0——一个、两个、三个分别对应上限 9、99、999。# ??/?? 这样的混合掩码先把整数拆走,只展开小数部分;?/? 这样的假分数掩码展开整个值,所以 ?/? 下的 3.14159 出来是 22/7。每一步取当前值 B 的整数部分 A,折进下一个渐近分数。余数 B − A 降到 5e-8 以下循环就停,所以简单分数加浮点噪声仍会落回那个分数。某一步把分母顶过上限时,Excel 退回上一个渐近分数,而这一退可能落到 1/1,混合掩码又把它并进整数,于是 # ?/? 下的 12.9999 显示 13。下面把循环写成朴素的 Delphi,旁边配一个暴力最近分数搜索,你可以在自己的数字上看两条路在哪里分岔

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;               // 余数按零处理
    B := 1 / R;
    P2 := P1; Q2 := Q1;
    P1 := P;  Q1 := Q;
  end;
  if Q > MaxDen then       // 超过上限:退回一个渐近分数
    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 之所以在清单里,是因为寻常两位小数输入同样会分岔——10/37 对 17/63,而这个值毫无出奇之处。两条规则多常分岔取决于你的数据:0.5 或 0.375 这类真分数怎么走都一样,测量值和算出来的比值就远没那么可靠。代码用 Trunc 顶替 Floor,因为输入永不为负。Excel 在分数之外处理符号,你的代码也该如此。参数必须是单元格实际存储的 Double,个中缘由下一节会讲得具体

0.99 在两位分数下会怎样

# ??/?? 下 Excel 把 0.99 显示为 ·98/99(本文所有 · 均代表一个空格字符)。根源是二进制浮点。字面量 0.99 没有精确的 Double,存储值比它低一丝。于是 1/(1/0.99 − 1) 求出约 98.9999999999994 而非 99,展开第二项变成 98,渐近分数 98/99 正好在上限之内。用精确十进制做同样的展开会得到 [0; 1, 99]:那个 99 把分母顶到 100,循环退回 1/1,单元格会显示 1······。这里,比 Excel 更讲究恰恰是 bug。把金额存成 Currency 或 TBcd、或从数据库重新解析十进制字符串再用精确算术展开的报表生成器,会在 Excel 打印 98/99 的地方打印整数 1。把存储的 Double 喂给格式化器,中间值全程留在浮点里。分数也只停留在显示层面:公式仍看到 0.99,HotXLS 即便在开启以显示精度为准(precision as displayed)的工作簿里,也让分数部分保持全精度

Excel 分数掩码 # ??/?? 下 0.99 的双路径图:存储的 Double 展开为 98/99,精确十进制冲破上限显示整数 1
存储的 Double 落在 98/99,精确十进制算术退回 1/1——这里比 Excel 更讲究才是 bug

Excel 如何填充与对齐分数区域

Excel 排布分数的方式与一列小数相同:分子在其槽位内右对齐,分母左对齐,斜杠因此落在每行同一位置。每个空槽按其占位符填充:? 留空格,0 写零,# 什么都不写。所以 # ??/?? 下的 3.14159 读作 3··1/7·:一个空格是掩码里的分隔符,一个填在 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 保留整数字位(零也不例外),并把分隔符、分子、斜杠、分母各换成一个空格,于是 # ?/? 下的 12 变成 12····,# ??/100 下则后跟七个空格。分子占位符换成 #,填充就消失。零整数则相反:# 槽位对 0.5 一个字不打印,分隔符仅以一个空格幸存;0 ?/? 强制打出那个数字;假分数掩码则从不丢掉分数部分

这些填充怎么处置,取决于文本去向。等宽文本报表和右对齐的 PDF 表格单元格请保留——对齐斜杠正是那里的全部意义。CSV 和 JSON 请剪掉:尾部空格会把 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,向上凑满整八。两族之间按分母对读者的意义来选。固定分母适合按固定步长报价的东西:英制长度、钻头规格、按八分之一报价的老股价。没人想读木材尺寸的 3/7 英寸。近似掩码适合比值和测量值——那里没有任何特定分母承载意义

在 Delphi 里用 FormattedText 读 Excel 的分数文本

HotXLS 里你很少需要自己写循环。TXLSXCell.FormattedText 用 lxStandard.pas 的 TXLSFormater.GetDisplayValue 经单元格的 NumberFormat 渲染存储值,渐近分数选择、填充和任何字面文本一次性到达同一个字符串。下面的过程把成对的值与掩码赋给单元格,并把每个结果打在括号之间,让尾部空格无处遁形。新建的 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)]

最后两个案例展示分数周围的东西。引号文本与反斜杠转义按普通字符打印,整数值的填充落在后缀之前,于是 "x"# ?/?" y" 把 -12 变成 -x12·····y。负号加在一切之前,前缀也不例外,除非像 (# ?/?) 这样的显式负数节自己写出符号;分数遵循与所有其他 Excel 数字格式相同的节与负号规则。值来自数据集而非单元格时,跳过工作簿:TXLSFormater 有无参构造函数,数值调用的正是 GetDisplayValue(Value, NumberFormat),FormattedText 也是这么做的

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 之外打印的任何分数,都应出自渐近分数循环,或直接出自 FormattedText,并用单元格自己的 Double 喂进去。这样纸上的 3 1/7 就是网格里的 3 1/7,而有了 HotXLS Delphi spreadsheet component,连循环都不用写就能拿到这段文本