diff --git a/Libraries/LibUnicode/CMakeLists.txt b/Libraries/LibUnicode/CMakeLists.txt index c491280687..2c7a490eac 100644 --- a/Libraries/LibUnicode/CMakeLists.txt +++ b/Libraries/LibUnicode/CMakeLists.txt @@ -1,5 +1,6 @@ set(SOURCES Calendar.cpp + Calendars/AdjustedEraCalendar.cpp Calendars/ChineseDangiCalendar.cpp CharacterTypes.cpp Collator.cpp diff --git a/Libraries/LibUnicode/Calendars/AdjustedEraCalendar.cpp b/Libraries/LibUnicode/Calendars/AdjustedEraCalendar.cpp new file mode 100644 index 0000000000..66d63fff70 --- /dev/null +++ b/Libraries/LibUnicode/Calendars/AdjustedEraCalendar.cpp @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2026, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include + +namespace Unicode { + +AdjustedEraCalendar::AdjustedEraCalendar(NonnullOwnPtr base_calendar, icu::Locale const& locale, UErrorCode& status, EraMode era_mode) + : icu::Calendar(base_calendar->getTimeZone(), locale, status) + , m_base_calendar(move(base_calendar)) + , m_era_mode(era_mode) +{ +} + +AdjustedEraCalendar::~AdjustedEraCalendar() = default; + +AdjustedEraCalendar::AdjustedEraCalendar(AdjustedEraCalendar const& other) + : icu::Calendar(other) + , m_base_calendar(adopt_own(*other.m_base_calendar->clone())) + , m_era_mode(other.m_era_mode) +{ +} + +AdjustedEraCalendar* AdjustedEraCalendar::clone() const +{ + return new AdjustedEraCalendar(*this); +} + +void AdjustedEraCalendar::handleComputeFields(int32_t julian_day, UErrorCode& status) +{ + if (icu_failure(status)) + return; + + auto time = static_cast(static_cast(julian_day) - EPOCH_START_AS_JULIAN_DAY) * U_MILLIS_PER_DAY; + + m_base_calendar->setTime(time, status); + if (icu_failure(status)) + return; + + auto extended_year = m_base_calendar->get(UCAL_EXTENDED_YEAR, status); + auto month = m_base_calendar->get(UCAL_MONTH, status); + auto ordinal_month = m_base_calendar->get(UCAL_ORDINAL_MONTH, status); + auto day_of_month = m_base_calendar->get(UCAL_DAY_OF_MONTH, status); + auto day_of_year = m_base_calendar->get(UCAL_DAY_OF_YEAR, status); + if (icu_failure(status)) + return; + + i32 era = 0; + i32 display_year = 0; + + switch (m_era_mode) { + case EraMode::SingleEra: + era = 1; + display_year = extended_year; + break; + + case EraMode::DualEra: + if (extended_year > 0) { + era = 0; + display_year = extended_year; + } else { + era = 1; + display_year = 1 - extended_year; + } + break; + } + + internalSet(UCAL_ERA, era); + internalSet(UCAL_YEAR, display_year); + internalSet(UCAL_EXTENDED_YEAR, extended_year); + internalSet(UCAL_MONTH, month); + internalSet(UCAL_ORDINAL_MONTH, ordinal_month); + internalSet(UCAL_DAY_OF_MONTH, day_of_month); + internalSet(UCAL_DAY_OF_YEAR, day_of_year); +} + +int64_t AdjustedEraCalendar::handleComputeMonthStart(int32_t extended_year, int32_t month, UBool use_month, UErrorCode& status) const +{ + if (icu_failure(status)) + return 0; + + auto& base_calendar = const_cast(*m_base_calendar); + base_calendar.clear(); + + base_calendar.set(UCAL_EXTENDED_YEAR, extended_year); + base_calendar.set(use_month ? UCAL_MONTH : UCAL_ORDINAL_MONTH, month); + base_calendar.set(UCAL_DAY_OF_MONTH, 1); + + auto time = base_calendar.getTime(status); + if (icu_failure(status)) + return 0; + + // handleComputeMonthStart must return the Julian day of the day BEFORE month start. + return EPOCH_START_AS_JULIAN_DAY + static_cast(time / U_MILLIS_PER_DAY) - 1; +} + +int32_t AdjustedEraCalendar::handleGetExtendedYear(UErrorCode& status) +{ + if (icu_failure(status)) + return 0; + + if (newerField(UCAL_EXTENDED_YEAR, UCAL_YEAR) == UCAL_EXTENDED_YEAR) + return internalGet(UCAL_EXTENDED_YEAR, 1); + + auto era = internalGet(UCAL_ERA); + auto year = internalGet(UCAL_YEAR, 1); + + switch (m_era_mode) { + case EraMode::SingleEra: + return year; + case EraMode::DualEra: + return era == 0 ? year : 1 - year; + } + + VERIFY_NOT_REACHED(); +} + +int32_t AdjustedEraCalendar::handleGetLimit(UCalendarDateFields field, ELimitType limit_type) const +{ + if (field == UCAL_ERA) { + switch (m_era_mode) { + case EraMode::SingleEra: + return 1; + case EraMode::DualEra: + return limit_type == UCAL_LIMIT_MINIMUM || limit_type == UCAL_LIMIT_GREATEST_MINIMUM ? 0 : 1; + } + } + + switch (limit_type) { + case UCAL_LIMIT_MINIMUM: + return m_base_calendar->getMinimum(field); + case UCAL_LIMIT_GREATEST_MINIMUM: + return m_base_calendar->getGreatestMinimum(field); + case UCAL_LIMIT_LEAST_MAXIMUM: + return m_base_calendar->getLeastMaximum(field); + case UCAL_LIMIT_MAXIMUM: + return m_base_calendar->getMaximum(field); + default: + VERIFY_NOT_REACHED(); + } +} + +UOBJECT_DEFINE_RTTI_IMPLEMENTATION(AdjustedEraCalendar) + +} diff --git a/Libraries/LibUnicode/Calendars/AdjustedEraCalendar.h b/Libraries/LibUnicode/Calendars/AdjustedEraCalendar.h new file mode 100644 index 0000000000..419aefa07a --- /dev/null +++ b/Libraries/LibUnicode/Calendars/AdjustedEraCalendar.h @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include + +#include + +namespace Unicode { + +// Custom icu::Calendar subclass to fix era handling for calendars where ICU's default behavior does not match ECMA-402 +// requirements. +// +// Coptic calendar (SingleEra mode): ICU's Coptic calendar defines two eras (0 and 1), but the CLDR era 0 name is an +// empty string, causing formatToParts to omit the era entirely for pre-epoch dates. ECMA-402 treats Coptic as a +// single-era calendar where all dates use the same era ("Anno Martyrum") and the year can be negative. This subclass +// forces a fixed era value for all dates. +// +// Islamic calendars (DualEra mode): ICU's Islamic calendar implementations always set ERA=0 and allow the year to go +// negative for dates before the Hijra (622 CE). However, ICU's CLDR data defines two eras (era 0 "Anno Hegirae" and +// era 1 "Before Hijrah"), and ECMA-402 expects pre-epoch dates to use a distinct era. This subclass maps positive +// years to ERA=0 (AH) and non-positive years to ERA=1 (BH) with a positive year value. +class AdjustedEraCalendar final : public icu::Calendar { +public: + enum class EraMode : u8 { + SingleEra, + DualEra, + }; + + AdjustedEraCalendar(NonnullOwnPtr base_calendar, icu::Locale const&, UErrorCode&, EraMode); + ~AdjustedEraCalendar() override; + + char const* getType() const override { return m_base_calendar->getType(); } + + AdjustedEraCalendar* clone() const override; + + UClassID getDynamicClassID() const override; + static UClassID getStaticClassID(); + +private: + AdjustedEraCalendar(AdjustedEraCalendar const&); + + void handleComputeFields(int32_t julian_day, UErrorCode&) override; + int64_t handleComputeMonthStart(int32_t extended_year, int32_t month, UBool use_month, UErrorCode&) const override; + int32_t handleGetExtendedYear(UErrorCode&) override; + int32_t handleGetLimit(UCalendarDateFields field, ELimitType limit_type) const override; + + UBool haveDefaultCentury() const override { return m_base_calendar->haveDefaultCentury(); } + UDate defaultCenturyStart() const override { return m_base_calendar->defaultCenturyStart(); } + int32_t defaultCenturyStartYear() const override { return m_base_calendar->defaultCenturyStartYear(); } + + NonnullOwnPtr m_base_calendar; + EraMode m_era_mode; +}; + +} diff --git a/Libraries/LibUnicode/Calendars/ChineseDangiCalendar.cpp b/Libraries/LibUnicode/Calendars/ChineseDangiCalendar.cpp index 02ee22f173..e5dc97a066 100644 --- a/Libraries/LibUnicode/Calendars/ChineseDangiCalendar.cpp +++ b/Libraries/LibUnicode/Calendars/ChineseDangiCalendar.cpp @@ -7,19 +7,11 @@ #include #include #include +#include #include namespace Unicode { -// https://github.com/unicode-org/icu/blob/main/icu4c/source/i18n/gregoimp.h#L127 -static constexpr i64 EPOCH_START_AS_JULIAN_DAY = 2440588; - -// https://en.wikipedia.org/wiki/Chinese_calendar_correspondence_table -static constexpr i32 CHINESE_CALENDAR_FIRST_YEAR = -2637; - -// https://en.wikipedia.org/wiki/Dangun_calendar -static constexpr i32 DANGI_CALENDAR_FIRST_YEAR = -2333; - // This is a bit weird, but lets us delegate otherwise protected methods to the original icu4c calendar. The static_cast // is safe here because these are virtual methods dispatched through the vtable. struct CalendarAccessor : icu::Calendar { diff --git a/Libraries/LibUnicode/Calendars/Constants.h b/Libraries/LibUnicode/Calendars/Constants.h new file mode 100644 index 0000000000..d1cf05e5ba --- /dev/null +++ b/Libraries/LibUnicode/Calendars/Constants.h @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include + +namespace Unicode { + +// https://github.com/unicode-org/icu/blob/main/icu4c/source/i18n/gregoimp.h#L127 +constexpr inline i64 EPOCH_START_AS_JULIAN_DAY = 2440588; + +// https://en.wikipedia.org/wiki/Chinese_calendar_correspondence_table +constexpr inline i32 CHINESE_CALENDAR_FIRST_YEAR = -2637; + +// https://en.wikipedia.org/wiki/Dangun_calendar +constexpr inline i32 DANGI_CALENDAR_FIRST_YEAR = -2333; + +} diff --git a/Libraries/LibUnicode/DateTimeFormat.cpp b/Libraries/LibUnicode/DateTimeFormat.cpp index dcf6313b2d..5e9e967f76 100644 --- a/Libraries/LibUnicode/DateTimeFormat.cpp +++ b/Libraries/LibUnicode/DateTimeFormat.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -633,6 +634,12 @@ static void apply_time_zone_to_formatter(icu::SimpleDateFormat& formatter, icu:: if (auto const* calendar_type = calendar->getType(); first_is_one_of(calendar_type, "chinese"sv, "dangi"sv)) { calendar = new ChineseDangiCalendar(adopt_own(*calendar), locale, status); verify_icu_success(status); + } else if (calendar_type == "coptic"sv) { + calendar = new AdjustedEraCalendar(adopt_own(*calendar), locale, status, AdjustedEraCalendar::EraMode::SingleEra); + verify_icu_success(status); + } else if (first_is_one_of(calendar_type, "islamic"sv, "islamic-civil"sv, "islamic-tbla"sv, "islamic-umalqura"sv)) { + calendar = new AdjustedEraCalendar(adopt_own(*calendar), locale, status, AdjustedEraCalendar::EraMode::DualEra); + verify_icu_success(status); } else if (auto* gregorian_calendar = as_if(*calendar)) { // https://tc39.es/ecma262/#sec-time-values-and-time-range // A time value supports a slightly smaller range of -8,640,000,000,000,000 to 8,640,000,000,000,000 milliseconds. diff --git a/Tests/LibJS/Runtime/builtins/Intl/DateTimeFormat/DateTimeFormat.prototype.formatToParts.js b/Tests/LibJS/Runtime/builtins/Intl/DateTimeFormat/DateTimeFormat.prototype.formatToParts.js index c55eda8a67..89cc3b16a7 100644 --- a/Tests/LibJS/Runtime/builtins/Intl/DateTimeFormat/DateTimeFormat.prototype.formatToParts.js +++ b/Tests/LibJS/Runtime/builtins/Intl/DateTimeFormat/DateTimeFormat.prototype.formatToParts.js @@ -339,6 +339,84 @@ describe("special cases", () => { }); }); +describe("era formatting", () => { + function makeDate(year, month, day) { + let date = new Date(0); + date.setFullYear(year, month, day); + date.setHours(0, 0, 0, 0); + return date; + } + + test("islamic calendars produce era parts", () => { + for (const calendar of ["islamic-civil", "islamic-tbla", "islamic-umalqura"]) { + const formatter = new Intl.DateTimeFormat("en", { + calendar, + era: "long", + year: "numeric", + timeZone: "UTC", + }); + + const date = makeDate(2025, 5, 15); + const parts = formatter.formatToParts(date); + const eraPart = parts.find(({ type }) => type === "era"); + expect(eraPart).not.toBeUndefined(); + expect(eraPart.value.length).toBeGreaterThan(0); + } + }); + + test("islamic calendars produce distinct eras for pre and post Hijra dates", () => { + for (const calendar of ["islamic-civil", "islamic-tbla", "islamic-umalqura"]) { + const formatter = new Intl.DateTimeFormat("en", { + calendar, + era: "long", + year: "numeric", + timeZone: "UTC", + }); + + const postHijra = formatter.formatToParts(makeDate(2025, 5, 15)); + const preHijra = formatter.formatToParts(makeDate(600, 5, 15)); + + const postEra = postHijra.find(({ type }) => type === "era"); + const preEra = preHijra.find(({ type }) => type === "era"); + + expect(postEra).not.toBeUndefined(); + expect(preEra).not.toBeUndefined(); + expect(postEra.value).not.toBe(preEra.value); + } + }); + + test("coptic calendar produces era parts", () => { + const formatter = new Intl.DateTimeFormat("en", { + calendar: "coptic", + era: "long", + year: "numeric", + timeZone: "UTC", + }); + + for (const isoYear of [250, 2025]) { + const parts = formatter.formatToParts(makeDate(isoYear, 5, 15)); + const eraPart = parts.find(({ type }) => type === "era"); + expect(eraPart).not.toBeUndefined(); + expect(eraPart.value.length).toBeGreaterThan(0); + } + }); + + test("chinese and dangi calendars do not produce era parts", () => { + for (const calendar of ["chinese", "dangi"]) { + const formatter = new Intl.DateTimeFormat("en", { + calendar, + era: "long", + year: "numeric", + timeZone: "UTC", + }); + + const parts = formatter.formatToParts(makeDate(2025, 5, 15)); + const eraPart = parts.find(({ type }) => type === "era"); + expect(eraPart).toBeUndefined(); + } + }); +}); + describe("Temporal objects", () => { const formatter = new Intl.DateTimeFormat("en", { calendar: "iso8601",