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:
parent
3c0d0d248c
commit
34d7a8fa69
7 changed files with 319 additions and 9 deletions
|
|
@ -1,5 +1,6 @@
|
|||
set(SOURCES
|
||||
Calendar.cpp
|
||||
Calendars/AdjustedEraCalendar.cpp
|
||||
Calendars/ChineseDangiCalendar.cpp
|
||||
CharacterTypes.cpp
|
||||
Collator.cpp
|
||||
|
|
|
|||
150
Libraries/LibUnicode/Calendars/AdjustedEraCalendar.cpp
Normal file
150
Libraries/LibUnicode/Calendars/AdjustedEraCalendar.cpp
Normal 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)
|
||||
|
||||
}
|
||||
60
Libraries/LibUnicode/Calendars/AdjustedEraCalendar.h
Normal file
60
Libraries/LibUnicode/Calendars/AdjustedEraCalendar.h
Normal 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;
|
||||
};
|
||||
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
22
Libraries/LibUnicode/Calendars/Constants.h
Normal file
22
Libraries/LibUnicode/Calendars/Constants.h
Normal 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;
|
||||
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue