将 "julian dates" 转换为 unixtimestamp 并返回 DateTime 时出现错误

Bug with converting "julian dates" to unixtimestamp and back to DateTime

今天在我的项目上工作 - 发现了一个奇怪的错误。 使用 symfony/form (5.4.5) 组件,发现日期在转换后(在 Symfony 端)作为 0001-01-01 传递,返回为 0000-12-30.

所以我做了一些调查(从 symfony/form 获取代码,它正在用日期做这个转换的东西,发现一件非常有趣的事情 - 日期,它在 1582 年之前(从 Julian 日历移动到 Gregorian 日历) 转换不正确。

下面我放了一个代码示例及其输出。

    $dateFormat = 2;
    $timeFormat = -1;
    $timezone = new \DateTimeZone('UTC');
    $calendar = 1;
    $pattern = 'yyyy-MM-dd';

    $dateFormatter = new \IntlDateFormatter(\Locale::getDefault(), $dateFormat, $timeFormat, $timezone, $calendar, $pattern);

    $dates = ['0001-01-01', '0002-01-01', '1000-01-01', '1582-01-01', '1583-01-01', '1600-01-01', '1800-01-01'];
    foreach ($dates as $dateInput) {
        $timestamp = $dateFormatter->parse($dateInput);

        $dateTime = new \DateTime(date('Y-m-d', $timestamp), new \DateTimeZone('Europe/Berlin'));
        $dateOutput = $dateTime->format('Y-m-d');
        echo $dateInput . " " .$dateOutput . PHP_EOL;
    }
0001-01-01 0000-12-30 - BAD
0002-01-01 0001-12-30 - BAD
1000-01-01 1000-01-06 - BAD
1582-01-01 1582-01-11 - BAD
1583-01-01 1583-01-01 - GOOD
1600-01-01 1600-01-01 - GOOD
1800-01-01 1800-01-01 - GOOD

我想说这并不完全是错误,只是一种不同的解释,或者是对一个棘手问题的不同解决方案选择:你如何在你使用的日历开始时倒计时?您看到的最终错误是混合选择 不同 解决方案的库的结果,因此误解了彼此的输出。

首先要知道的是,您使用的函数是建立在两个不同的库上的:

  • 作为 PHP“intl”扩展的一部分,IntlDateFormatter 建立在 ICU(Unicode 的国际组件)之上,由 Unicode 联盟维护
  • date 函数和 DateTime class 建立在 timelib 之上,由 Derick Rethans
  • 维护

正如您已经指出的那样,接下来要注意的是,他们在首次引入公历的日期开始出现分歧。我们可以比年份更具体:它是在 1582 年 10 月引入的,紧随其后的是儒略历 10 月 4 日和格里高利历 10 月 15 日。重要的是,这意味着世界上有 10 个不存在的日期会立即切换。

最后,让我们看看这两个库生成的实际时间戳,而不是来回格式化。请注意,Unix 时间戳应该表示在公历和 UTC 时区中表示为 1970-01-01 00:00:00.[= 的时间之后(或者,在这种情况下之前)的秒数。 22=]

为了使时间戳更易于阅读,让我们将它们分开以显示他们所说的在给定日期和 1970 年 1 月 1 日之间的天数。

$dateFormatter = new \IntlDateFormatter(\Locale::getDefault(), 2, -1, new \DateTimeZone('UTC'), 1, 'yyyy-MM-dd');

$dates = [
    '1582-10-03', '1582-10-04', '1582-10-05', '1582-10-06', '1582-10-07', '1582-10-08', '1582-10-09',
    '1582-10-10', '1582-10-11', '1582-10-12', '1582-10-13', '1582-10-14', '1582-10-15', '1582-10-16'
];
foreach ($dates as $dateInput) {
    $icuTimestamp = $dateFormatter->parse($dateInput);
    $timelibTimestamp = DateTimeImmutable::createFromFormat('Y-m-d|', $dateInput)->getTimestamp();
    
    $icuDays = abs(intval( $icuTimestamp / 60 / 60 / 24 ));
    $timeLibDays = abs(intval( $timelibTimestamp / 60 / 60 / 24 ));

    echo "Is {$dateInput} {$icuDays} days or {$timeLibDays} days before 1970?\n";
}

结果如下所示:

Is 1582-10-03 141429 days or 141439 days before 1970?
Is 1582-10-04 141428 days or 141438 days before 1970?
Is 1582-10-05 141427 days or 141437 days before 1970?
Is 1582-10-06 141426 days or 141436 days before 1970?
Is 1582-10-07 141425 days or 141435 days before 1970?
Is 1582-10-08 141424 days or 141434 days before 1970?
Is 1582-10-09 141423 days or 141433 days before 1970?
Is 1582-10-10 141422 days or 141432 days before 1970?
Is 1582-10-11 141421 days or 141431 days before 1970?
Is 1582-10-12 141420 days or 141430 days before 1970?
Is 1582-10-13 141419 days or 141429 days before 1970?
Is 1582-10-14 141418 days or 141428 days before 1970?
Is 1582-10-15 141427 days or 141427 days before 1970?
Is 1582-10-16 141426 days or 141426 days before 1970?

正如预期的那样,两个图书馆一致认为 1582-10-15 是 1970 年之前的 141427 天,但对更早的日期存在分歧:

  • timelib 使用的是“proleptic Gregorian calendar”,假设 1582-10-14 比 1582-10-15 早一天,依此类推;这在历史上是不准确的,但使用起来更简单
  • ICU 正在尝试按照当时欧洲人的方式来解释日期,因此 1582-10-04 仅比 1582-10-15 早 1 天;这在中间留下了一些模棱两可的日期,根据这种解释,这些日期根本不应该存在,ICU 将其解释为 Julian,奇怪的是 1582-10-05 给出的值与 1582-10-15

The ICU documentation 解释了这种 cut-over 行为,以及如何影响它。尽管没有完全记录,PHP 公开了相关方法,因此可以将 cut-over 设置为任意远的过去以匹配 timelib 行为:

$cal = IntlGregorianCalendar::createInstance();
$cal->setGregorianChange(PHP_INT_MIN);
$dateFormatter->setCalendar($cal);

将其添加到之前的代码中,我们得到匹配的输出:

Is 1582-10-03 141439 days or 141439 days before 1970?
Is 1582-10-04 141438 days or 141438 days before 1970?
Is 1582-10-05 141437 days or 141437 days before 1970?
Is 1582-10-06 141436 days or 141436 days before 1970?
Is 1582-10-07 141435 days or 141435 days before 1970?
Is 1582-10-08 141434 days or 141434 days before 1970?
Is 1582-10-09 141433 days or 141433 days before 1970?
Is 1582-10-10 141432 days or 141432 days before 1970?
Is 1582-10-11 141431 days or 141431 days before 1970?
Is 1582-10-12 141430 days or 141430 days before 1970?
Is 1582-10-13 141429 days or 141429 days before 1970?
Is 1582-10-14 141428 days or 141428 days before 1970?
Is 1582-10-15 141427 days or 141427 days before 1970?
Is 1582-10-16 141426 days or 141426 days before 1970?

注意:我相信 Symfony 跟踪器中的相应错误是 #29610