Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ It is intended to be used when calendar extension is not enabled.

Currently, functions available are:
- [`cal_days_in_month`](https://www.php.net/manual/en/function.cal-days-in-month.php) — Return the number of days in a month for a given year and calendar
- [`cal_from_jd`](https://www.php.net/manual/en/function.cal-from-jd.php) — Converts from Julian Day Count to a supported calendar
- [`cal_to_jd`](https://www.php.net/manual/en/function.cal-to-jd.php) — Converts from a supported calendar to Julian Day Count
- [`easter_date`](https://www.php.net/manual/en/function.easter-date.php) — Get Unix timestamp for midnight on Easter of a given year
- [`easter_days`](https://www.php.net/manual/en/function.easter-days.php) — Get number of days after March 21 on which Easter falls for a given year
Expand Down
7 changes: 7 additions & 0 deletions runtime/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ function cal_days_in_month(int $calendar, int $month, int $year): int
}
}

if (!function_exists('cal_from_jd')) {
function cal_from_jd(int $julian_day, int $calendar): array
{
return Calendar::cal_from_jd($julian_day, $calendar);
}
}

if (!function_exists('gregoriantojd')) {
function gregoriantojd(int $month, int $day, int $year): int
{
Expand Down
84 changes: 80 additions & 4 deletions src/Calendar.php
Original file line number Diff line number Diff line change
Expand Up @@ -247,13 +247,12 @@ public static function jdmonthname(int $julian_day, int $mode): string
return $monthNames[$month] ?? '';

case self::CAL_MONTH_FRENCH:
$frenchDate = French::jdtofrench($julian_day);
if ($frenchDate === '0/0/0') {
[$year, $month, $day] = French::sdnToFrench($julian_day);
if ($year <= 0) {
return '';
}
[$month, $day, $year] = explode('/', $frenchDate);

return self::FRENCH_MONTH_NAMES[(int) $month] ?? '';
return self::FRENCH_MONTH_NAMES[$month] ?? '';

default:
case self::CAL_MONTH_GREGORIAN_SHORT:
Expand All @@ -263,6 +262,83 @@ public static function jdmonthname(int $julian_day, int $mode): string
}
}

/**
* Converts from Julian Day Count to a supported calendar.
*
* @see https://www.php.net/manual/en/function.cal-from-jd.php
*
* @return array{date: string, month: int, day: int, year: int, dow: int|null, abbrevdayname: string, dayname: string, abbrevmonth: string, monthname: string}
*/
public static function cal_from_jd(int $julian_day, int $calendar): array
{
if ($calendar < 0 || $calendar >= self::CAL_NUM_CALS) {
throw new ValueError('cal_from_jd(): Argument #2 ($calendar) must be a valid calendar ID');
}

/* Get date components based on calendar type */
switch ($calendar) {
case self::CAL_GREGORIAN:
[$year, $month, $day] = Gregor::sdnToGregorian($julian_day);
$abbrevMonth = self::MONTH_NAMES_SHORT[$month] ?? '';
$monthName = self::MONTH_NAMES_LONG[$month] ?? '';
break;

case self::CAL_JULIAN:
[$year, $month, $day] = Julian::sdnToJulian($julian_day);
$abbrevMonth = self::MONTH_NAMES_SHORT[$month] ?? '';
$monthName = self::MONTH_NAMES_LONG[$month] ?? '';
break;

case self::CAL_JEWISH:
[$year, $month, $day] = Jewish::sdnToJewish($julian_day);
if ($year <= 0) {
/* Bug #71894: Jewish calendar with year 0 returns null dow */
return [
'date' => '0/0/0',
'month' => 0,
'day' => 0,
'year' => 0,
'dow' => null,
'abbrevdayname' => '',
'dayname' => '',
'abbrevmonth' => '',
'monthname' => '',
];
}
$isLeapYear = (($year * 7 + 1) % 19) < 7;
$monthNames = $isLeapYear ? self::JEWISH_MONTH_NAMES_LEAP : self::JEWISH_MONTH_NAMES;
$abbrevMonth = $monthNames[$month] ?? '';
$monthName = $monthNames[$month] ?? '';
break;

case self::CAL_FRENCH:
[$year, $month, $day] = French::sdnToFrench($julian_day);
$abbrevMonth = self::FRENCH_MONTH_NAMES[$month] ?? '';
$monthName = self::FRENCH_MONTH_NAMES[$month] ?? '';
break;
}

/* Calculate day of week using existing method */
/** @var int $dow */
$dow = self::jddayofweek($julian_day, self::CAL_DOW_DAYNO);
/** @var string $abbrevDayName */
$abbrevDayName = self::jddayofweek($julian_day, self::CAL_DOW_SHORT);
/** @var string $dayName */
$dayName = self::jddayofweek($julian_day, self::CAL_DOW_LONG);

return [
'date' => "{$month}/{$day}/{$year}",
'month' => $month,
'day' => $day,
'year' => $year,
'dow' => $dow,
'abbrevdayname' => $abbrevDayName,
'dayname' => $dayName,
'abbrevmonth' => $abbrevMonth,
'monthname' => $monthName,
];
}

private static function getMaxJulianDay(): int
{
return \PHP_INT_SIZE === 8 ? 106751993607888 : 2465443;
Expand Down
20 changes: 16 additions & 4 deletions src/French.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,21 +31,33 @@ public static function frenchtojd(int $month, int $day, int $year): int
* @see https://www.php.net/manual/en/function.jdtofrench.php
*/
public static function jdtofrench(int $julian_day): string
{
[$year, $month, $day] = self::sdnToFrench($julian_day);

return "{$month}/{$day}/{$year}";
}

/**
* Converts a SDN to French Republican year, month, day.
*
* @return array{int, int, int} [year, month, day]
*/
public static function sdnToFrench(int $sdn): array
{
/* French Republican calendar valid range: year 1-14 */
/* First valid: JD 2375840 (1/1/1), Last valid: JD 2380952 (13/5/14) */
if ($julian_day < 2375840 || $julian_day > 2380952) {
return '0/0/0';
if ($sdn < 2375840 || $sdn > 2380952) {
return [0, 0, 0];
}

$temp = ($julian_day - self::FRENCH_SDN_OFFSET) * 4 - 1;
$temp = ($sdn - self::FRENCH_SDN_OFFSET) * 4 - 1;
$year = (int) ($temp / self::DAYS_PER_4_YEARS);
$dayOfYear = (int) (($temp % self::DAYS_PER_4_YEARS) / 4);

$month = (int) ($dayOfYear / self::DAYS_PER_MONTH) + 1;
$day = ($dayOfYear % self::DAYS_PER_MONTH) + 1;

return "{$month}/{$day}/{$year}";
return [$year, $month, $day];
}

/**
Expand Down
4 changes: 3 additions & 1 deletion src/Gregor.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ public static function gregoriantojd(int $month, int $day, int $year): int
*/
public static function sdnToGregorian(int $sdn): array
{
if ($sdn <= 0) {
/* Overflow protection: check if sdn would cause overflow in calculations */
$maxSdn = (int) ((\PHP_INT_MAX - 4 * self::GREGOR_SDN_OFFSET) / 4);
if ($sdn <= 0 || $sdn > $maxSdn) {
return [0, 0, 0];
}

Expand Down
5 changes: 4 additions & 1 deletion src/Julian.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ public static function jdtojulian(int $julian_day): string
*/
public static function sdnToJulian(int $sdn): array
{
if ($sdn <= 0) {
/* Overflow protection: check if sdn would cause overflow in calculations */
$maxSdn = (int) ((\PHP_INT_MAX - self::JULIAN_SDN_OFFSET * 4 + 1) / 4);
$minSdn = (int) (\PHP_INT_MIN / 4);
if ($sdn <= 0 || $sdn > $maxSdn || $sdn < $minSdn) {
return [0, 0, 0];
}

Expand Down
129 changes: 129 additions & 0 deletions test/Spec/CalendarSpec.php
Original file line number Diff line number Diff line change
Expand Up @@ -454,4 +454,133 @@ public function it_returns_empty_string_for_jewish_year_zero(): void
{
$this->jdmonthname(347997, Calendar::CAL_MONTH_JEWISH)->shouldReturn('');
}

/**
* Test cases from PHP source: ext/calendar/tests/cal_from_jd.phpt
*/
public function it_converts_julian_day_to_gregorian_calendar(): void
{
$result = $this->cal_from_jd(1748326, Calendar::CAL_GREGORIAN);
$result->shouldBeArray();
$result->shouldHaveKeyWithValue('date', '8/26/74');
$result->shouldHaveKeyWithValue('month', 8);
$result->shouldHaveKeyWithValue('day', 26);
$result->shouldHaveKeyWithValue('year', 74);
$result->shouldHaveKeyWithValue('dow', 0);
$result->shouldHaveKeyWithValue('abbrevdayname', 'Sun');
$result->shouldHaveKeyWithValue('dayname', 'Sunday');
$result->shouldHaveKeyWithValue('abbrevmonth', 'Aug');
$result->shouldHaveKeyWithValue('monthname', 'August');
}

public function it_converts_julian_day_to_julian_calendar(): void
{
$result = $this->cal_from_jd(1748324, Calendar::CAL_JULIAN);
$result->shouldBeArray();
$result->shouldHaveKeyWithValue('date', '8/26/74');
$result->shouldHaveKeyWithValue('month', 8);
$result->shouldHaveKeyWithValue('day', 26);
$result->shouldHaveKeyWithValue('year', 74);
$result->shouldHaveKeyWithValue('dow', 5);
$result->shouldHaveKeyWithValue('abbrevdayname', 'Fri');
$result->shouldHaveKeyWithValue('dayname', 'Friday');
$result->shouldHaveKeyWithValue('abbrevmonth', 'Aug');
$result->shouldHaveKeyWithValue('monthname', 'August');
}

public function it_converts_julian_day_to_jewish_calendar(): void
{
$result = $this->cal_from_jd(374867, Calendar::CAL_JEWISH);
$result->shouldBeArray();
$result->shouldHaveKeyWithValue('date', '8/26/74');
$result->shouldHaveKeyWithValue('month', 8);
$result->shouldHaveKeyWithValue('day', 26);
$result->shouldHaveKeyWithValue('year', 74);
$result->shouldHaveKeyWithValue('dow', 4);
$result->shouldHaveKeyWithValue('abbrevdayname', 'Thu');
$result->shouldHaveKeyWithValue('dayname', 'Thursday');
$result->shouldHaveKeyWithValue('abbrevmonth', 'Nisan');
$result->shouldHaveKeyWithValue('monthname', 'Nisan');
}

public function it_converts_julian_day_zero_to_french_calendar(): void
{
$result = $this->cal_from_jd(0, Calendar::CAL_FRENCH);
$result->shouldBeArray();
$result->shouldHaveKeyWithValue('date', '0/0/0');
$result->shouldHaveKeyWithValue('month', 0);
$result->shouldHaveKeyWithValue('day', 0);
$result->shouldHaveKeyWithValue('year', 0);
$result->shouldHaveKeyWithValue('dow', 1);
$result->shouldHaveKeyWithValue('abbrevdayname', 'Mon');
$result->shouldHaveKeyWithValue('dayname', 'Monday');
$result->shouldHaveKeyWithValue('abbrevmonth', '');
$result->shouldHaveKeyWithValue('monthname', '');
}

/**
* Test case from PHP source: ext/calendar/tests/cal_from_jd_error1.phpt
*/
public function it_throws_exception_for_invalid_calendar_in_cal_from_jd(): void
{
$this->shouldThrow(new ValueError('cal_from_jd(): Argument #2 ($calendar) must be a valid calendar ID'))
->duringCal_from_jd(1748326, -1)
;
}

/**
* Test case from PHP source: ext/calendar/tests/bug71894.phpt
* Jewish calendar with year 0 returns null dow
*/
public function it_returns_null_dow_for_jewish_calendar_at_year_zero(): void
{
$result = $this->cal_from_jd(347997, Calendar::CAL_JEWISH);
$result->shouldBeArray();
$result->shouldHaveKeyWithValue('date', '0/0/0');
$result->shouldHaveKeyWithValue('month', 0);
$result->shouldHaveKeyWithValue('day', 0);
$result->shouldHaveKeyWithValue('year', 0);
$result->shouldHaveKeyWithValue('dow', null);
$result->shouldHaveKeyWithValue('abbrevdayname', '');
$result->shouldHaveKeyWithValue('dayname', '');
$result->shouldHaveKeyWithValue('abbrevmonth', '');
$result->shouldHaveKeyWithValue('monthname', '');
}

/**
* Test cases from PHP source: ext/calendar/tests/bug53574_2.phpt (64-bit)
* Integer overflow in SdnToJulian
*/
public function it_handles_julian_overflow_on_64bit(): void
{
if (\PHP_INT_SIZE !== 8) {
return;
}

$result = $this->cal_from_jd(3315881921229094912, Calendar::CAL_JULIAN);
$result->shouldHaveKeyWithValue('date', '0/0/0');
$result->shouldHaveKeyWithValue('month', 0);
$result->shouldHaveKeyWithValue('day', 0);
$result->shouldHaveKeyWithValue('year', 0);
$result->shouldHaveKeyWithValue('dow', 3);
$result->shouldHaveKeyWithValue('abbrevdayname', 'Wed');
$result->shouldHaveKeyWithValue('dayname', 'Wednesday');
}

/**
* Test cases from PHP source: ext/calendar/tests/bug55797_2.phpt (64-bit)
* Integer overflow in SdnToGregorian
*/
public function it_handles_gregorian_overflow_on_64bit(): void
{
if (\PHP_INT_SIZE !== 8) {
return;
}

$result = $this->cal_from_jd(9223372036854743639, Calendar::CAL_GREGORIAN);
$result->shouldHaveKeyWithValue('date', '0/0/0');
$result->shouldHaveKeyWithValue('month', 0);
$result->shouldHaveKeyWithValue('day', 0);
$result->shouldHaveKeyWithValue('year', 0);
}
}