LibUnicode: Handle ICU vs ECMA-402 era formatting discrepancies

ICU's Islamic calendar implementations always set ERA=0, even for dates
before the Hijra (622 CE), using negative year values instead. However,
the CLDR defines two eras: "Anno Hegirae" (era 0) and "Before Hijrah"
(era 1). ECMA-402 expects distinct era names in formatToParts output.
Similarly, ICU's Coptic calendar has an empty CLDR era 0 name, causing
the era parts to be omitted entirely from formatted output.

This patch adds another icu::Calendar subclass to handle these cases.
This commit is contained in:
Timothy Flynn 2026-03-13 08:12:35 -04:00 committed by Tim Flynn
parent 3c0d0d248c
commit 34d7a8fa69
7 changed files with 319 additions and 9 deletions

View file

@ -1,5 +1,6 @@
set(SOURCES
Calendar.cpp
Calendars/AdjustedEraCalendar.cpp
Calendars/ChineseDangiCalendar.cpp
CharacterTypes.cpp
Collator.cpp

View file

@ -0,0 +1,150 @@
/*
* Copyright (c) 2026, Tim Flynn <trflynn89@ladybird.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <LibUnicode/Calendars/AdjustedEraCalendar.h>
#include <LibUnicode/Calendars/Constants.h>
#include <LibUnicode/ICU.h>
namespace Unicode {
AdjustedEraCalendar::AdjustedEraCalendar(NonnullOwnPtr<icu::Calendar> 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<UDate>(static_cast<i64>(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<icu::Calendar&>(*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<int64_t>(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)
}

View file

@ -0,0 +1,60 @@
/*
* Copyright (c) 2026, Tim Flynn <trflynn89@ladybird.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include <AK/NonnullOwnPtr.h>
#include <unicode/calendar.h>
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<icu::Calendar> 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<icu::Calendar> m_base_calendar;
EraMode m_era_mode;
};
}

View file

@ -7,19 +7,11 @@
#include <AK/Time.h>
#include <LibUnicode/Calendar.h>
#include <LibUnicode/Calendars/ChineseDangiCalendar.h>
#include <LibUnicode/Calendars/Constants.h>
#include <LibUnicode/ICU.h>
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 {

View file

@ -0,0 +1,22 @@
/*
* Copyright (c) 2026, Tim Flynn <trflynn89@ladybird.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include <AK/Types.h>
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;
}

View file

@ -10,6 +10,7 @@
#include <AK/GenericShorthands.h>
#include <AK/StringBuilder.h>
#include <AK/TypeCasts.h>
#include <LibUnicode/Calendars/AdjustedEraCalendar.h>
#include <LibUnicode/Calendars/ChineseDangiCalendar.h>
#include <LibUnicode/DateTimeFormat.h>
#include <LibUnicode/ICU.h>
@ -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<icu::GregorianCalendar>(*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.

View file

@ -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",