Есть широко известный узких кругах баг в Excel, Office и (по идее) некоторых еще прочих электронных таблицах.
Microsoft Excel has, since its earliest versions, incorrectly considered 1900 to be a leap year, and therefore that February 29 comes between February 28 and March 1 of that year. The bug originated from Lotus 1-2-3, and was purposely implemented in Excel for the purpose of backward compatibility. Microsoft has written an article about this bug, explaining the reasons for treating 1900 as a leap year.[7] This bug has been promoted into a requirement in the Ecma Office Open XML (OOXML) specification.[8][9]
А я хочу сымитировать поведение этой ерунды на .NET/C#
Проблема в том, что:
new DateTime(1900, 2, 29);
даёт ArgumentOutOfRangeException, а всякие манипуляции с JulianCalendar ни к чему не приводят, так как:
Currently, the JulianCalendar is not used by any of the cultures supported by the CultureInfo class. Therefore, the JulianCalendar class can be used only to calculate dates in the Julian calendar.
With the exception of the constructors that include a parameter of type Calendar and allow the elements of a date (that is, the month, the day, and the year) to reflect values in a designated calendar, both DateTime and DateTimeOffset values are always based on the Gregorian calendar.
И на выходе получаем просто 13.03.1900. Как-то взломать через Reflection тоже пока не получилось.
Как можно, не изобретая велосипед, в C# преобразовать такую дату в соответствующую строку, используя множество всех уже доступных строк форматирования?
ЗЫ. Пока вышел из положения заменой данной конкретной даты на 1896 г. (законно високосный по григорианскому) с последующим двойным Replace'ом по строке '1896' на '1900' и '96' на '00' Но может быть есть более кошерный способ?
Здравствуйте, fortnum, Вы писали:
F>ЗЫ. Пока вышел из положения заменой данной конкретной даты на 1896 г. (законно високосный по григорианскому) с последующим двойным Replace'ом по строке '1896' на '1900' и '96' на '00' Но может быть есть более кошерный способ?
Нет, потому что физически дата представляется длинным целым числом 100-наносекундных тиков после полуночи 1 января 1 года по Григорианскому календарю, и после последнего тика 1900.02.28 сразу идёт первый тик 1900.03.01, поэтому "1900-02-29" DateTime-ом в принципе быть не может, только текстом.
Здравствуйте, Xander Zerge, Вы писали:
XZ>Нет, потому что физически дата представляется длинным целым числом 100-наносекундных тиков после полуночи 1 января 1 года по Грегорианскому календарю, и после последнего тика 1900.02.28 сразу идёт первый тик 1900.03.01, поэтому "1900-02-29" DateTime-мом в принципе быть не может, только текстом.
Логично конечно, когда все к григорианскому календарю сводится. Проблема ведь именно в этом. Как перейти к другому календарю, не переписывая все форматирование дат. Фиг с ним с Excel'ем. Забудем о нём пока.
Вот есть еще как минимум один календарь. В юлианском 29.02.1900 — это законный день, потому что по нему 1900й год — високосный. И 1 тик в юлианском равен 1 тику в григорианском. То есть по юлианскому календарю после последнего тика 28.02.1900 наступает как раз 29.02.1900. Тики одни и те же, просто обозначение дней разное. Нет технических сложностей перевести день из юлианского в григорианский и обратно (хотя тут я не буду утверждать, может там и есть какая-то хитрость, надо разобраться). Вот (вроде в браузере должно работать) получаем, что 29.02.1900 по юлианскому = 13.03.2900 по григорианскому:
using System;
using System.Globalization;
var dateTime = new DateTime(1900, 2, 29, 23, 59, 59, new JulianCalendar());
Console.WriteLine(dateTime);
Но неужели никто не сделал форматирование этих юлианских дат в строку? Пока только Noda Time глянул мельком, но как я понял, она тоже это не умеет, вывести 29.02.1900 по юлианскому в строку.
Для юлианских дат нужны отдельные какие-то алгоритмы форматирования строк, чтобы выводить даты по тем же строкам форматирования, что и григорианские? Или там какой-то общий всё же алгоритм? Мн кажется, что должен быть общий, но пока еще не вникал.
Здравствуйте, fortnum, Вы писали:
F>Вот есть еще как минимум один календарь. В юлианском 29.02.1900 — это законный день, потому что по нему 1900й год — високосный. И 1 тик в юлианском равен 1 тику в григорианском. То есть по юлианскому календарю после последнего тика 28.02.1900 наступает как раз 29.02.1900. Тики одни и те же, просто обозначение дней разное. Нет технических сложностей перевести день из юлианского в григорианский и обратно (хотя тут я не буду утверждать, может там и есть какая-то хитрость, надо разобраться). Вот (вроде в браузере должно работать) получаем, что 29.02.1900 по юлианскому = 13.03.2900 по григорианскому: F>
F>using System;
F>using System.Globalization;
F>var dateTime = new DateTime(1900, 2, 29, 23, 59, 59, new JulianCalendar());
Вот здесь dateTime уже григорианская, про JulianCalendar() уже можно забыть. Он используется только в конструкторе, чтобы привести переданные год, месяц и день к григорианскому.
F>Console.WriteLine(dateTime);
F>
F>Но неужели никто не сделал форматирование этих юлианских дат в строку? Пока только Noda Time глянул мельком, но как я понял, она тоже это не умеет, вывести 29.02.1900 по юлианскому в строку.
Нет, потому что юлианской даты физически нет. Только григорианская. Такова спецификация DateTime.
F>Для юлианских дат нужны отдельные какие-то алгоритмы форматирования строк, чтобы выводить даты по тем же строкам форматирования, что и григорианские? Или там какой-то общий всё же алгоритм? Мн кажется, что должен быть общий, но пока еще не вникал.
Да, нужен алгоритм, который из григорианской DateTime найдёт юлианские год, день и месяц и выведет в строку. Как уже было написано, такого алгоритма во фреймворке нет, потому что нет культуры, к которой его можно было бы привязать, а в форматирование даты можно передавать культуру, но не сам календарь. Можешь сделать такой метод-расширение.
Здравствуйте, Xander Zerge, Вы писали:
F>>Для юлианских дат нужны отдельные какие-то алгоритмы форматирования строк, чтобы выводить даты по тем же строкам форматирования, что и григорианские? Или там какой-то общий всё же алгоритм? Мн кажется, что должен быть общий, но пока еще не вникал. XZ>Да, нужен алгоритм, который из григорианской DateTime найдёт юлианские год, день и месяц и выведет в строку. Как уже было написано, такого алгоритма во фреймворке нет, потому что нет культуры, к которой его можно было бы привязать, а в форматирование даты можно передавать культуру, но не сам календарь. Можешь сделать такой метод-расширение.
Пока еще не въехал в тему, может и глупость скажу, но ведь у меня уже имеются юлианские и год, и месяц и день (29.02.1900), и алгоритм есть, JulianCalendar. То есть сами числа найти не вопрос. Но со строками... как не изобретать велосипед именно со строками форматирования? Разбор строк форматирования с нуля городить самому? Как вывести эти юлианские год, месяц и день (и время, кстати говоря) в стандартные строки форматирования?
Что-то не улыбается самому все эти 'g', 'dd', 'yy', 'yyyy' и прочие 'dddd', не говоря о стандартных, еще и с привязкой к културе парсить, чтобы ради только одного, вывести не существующую в григорианском календаре дату... которая законно есть в юлианском... которого не существует... потому что есть григорианский... который не понимает эту 29.02.1900. Что-то ерунда какая-то, или чего я не понимаю?
Console.WriteLine(dateTime.ToString("-= yy =- | g | mm:MM'/'dd'/'yyyy -- %h - dddd.ff"));
// -= 00 =- | н. э. | 00:03/13/1900 -- 12 - вторник.00
Здравствуйте, fortnum, Вы писали:
F>Как можно, не изобретая велосипед, в C# преобразовать такую дату в соответствующую строку, используя множество всех уже доступных строк форматирования?
Вы прям заинтриговали... Спасибо, оказалось очень интересным, заставило покопаться...
В общем, что у меня получилось...
Да, вы абсолютно правы, смотря в сторону календарей.
В целом, DateTime — это просто число тиков от условной стартовой точки.
А вот в Calendar (ну и его потомки), должны отвечать за то:
— что это за точка,
— как из тиков получать год, день, месяц, эру, часы, минуты, ... (и т.д. — что там заложено в каждой системе лето/время исчисления)
— и наоборот — из элементов даты/времени конкретной системы получать тики
— как выполнять всевозможные операции над датами (например, прибавить X дней скорее всего будет везде одинаково, т.к. дни имеют фиксированную продолжительность, а вот прибавить Y месяцев в том же григорианском календаре зависит от конкретной даты, т.к. месяцы нужно перевести в дни с учетом разной длины месяцев в году, а также с учетом високосных лет, ...)
— ...
Всё выглядит довольно красиво:
— получаешь дату в определенном календаре (с его трактовкой, что есть, например 1.01.1987 20:23:00 например в том же григорианском)
— все манипуляции выполняешь с нужным тебе календарем (например, в юлианском)
— и под конец выводишь или в том календаре, в котором манипулировал (в нашем случае — юлианском) или в том, котором нужно тебе (получая автоматом перевод в нужный календарь)
Но(!!!!) весь .Net "захардкожен" на использование григорианского календаря.
В частности:
— если вы создаете DateTime без явного указания календаря — он создается по грегорианскому.
— все операции в самом DateTime делаются без учета календаря, т.е. строго по григорианскому. Хочешь другой календарь — вызывай явно его методы.
— все объекты глобализации, связанные с датой и временем (DateTimeFormatInfo, сам CultureInfo, ...), по-умолчанию используют григорианский календарь и не допускают (по крайней мере, через публичный интерфейс) никаких других календарей (например, у того же DateTimeFormatInfo свойство Calendar имеет и getter, и setter но в setter идет проверка, что вы выставляете календарь из списка разрешенных для той культуры, с которой связан текущий DateTimeFormatInfo — и всё, приехали). Т.е. без хаков не обойдешься
Поэтому ваша задача по идее сводится к 2-м:
— создать такой календарь, который будет работать как привычный нам григорианский, но допускать дату 29.02.1900
— заставить .Net окружение работать с этим календарем.
Давайте начнем с конца — как подсунуть свой календарь (например, попробуем везде подсовывать юлианский календарь).
Создание:
var calendar = new JulianCalendar();
var date1 = new DateTime(1900, 2, 29, calendar);
var date2 = calendar.ToDateTime(1900, 2, 29, 0, 0, 0, 0);
Console.WriteLine(date1 == date2); // True
Модификация (как видите, по юлианскому календарю 1900 — високосный, поэтому результат прибавления (а мы при печати возвращаемся к григорианскому, который "захордкожен") — на 1 день больше):
var calendar1 = new GregorianCalendar();
var calendar2 = new JulianCalendar();
var date = new DateTime(1900, 1, 31);
var date1 = date.AddYears(1);
var date2 = calendar1.AddYears(date, 1);
var date3 = calendar2.AddYears(date, 1);
Console.WriteLine(date1); // 01/31/1901 00:00:00
Console.WriteLine(date2); // 01/31/1901 00:00:00
Console.WriteLine(date3); // 02/01/1901 00:00:00
Парсинг/Вывод (здесь пришлось обходит setter от DateTimeFormatInfo и напрямую устанавливать приватное поле calendar — после этого, DateTimeFormatInfo можно передавать как IFormatProvider):
var calendar = new JulianCalendar();
var formatInfo = new DateTimeFormatInfo();
var calendarField = formatInfo.GetType().GetField(
"calendar",
System.Reflection.BindingFlags.NonPublic
| System.Reflection.BindingFlags.Instance);
calendarField.SetValue(formatInfo, calendar);
var date = new DateTime(1900, 2, 29, calendar);
Console.WriteLine(date.ToString(formatInfo));
var date1 = DateTime.Parse("02/29/1900", formatInfo);
Console.WriteLine(date1.ToString(formatInfo));
Прошу прощения, что немного затянул — оказалось, что не так всё и просто, да и выходные (сами понимаете — хочется немного выдохнуть между рабочими неделями). МР>Давайте начнем с конца — как подсунуть свой календарь (например, попробуем везде подсовывать юлианский календарь).
Давайте теперь расскажу, что у меня набралось по самим календарям.
По идее, если вам нужен новый нестандартный календарь, то вы просто наследуетесь от Calendar или одного из его потомков (если он вам подходит больше), например, от GregorianCalendar, перекрываете те методы, которые вам нужны и (ура!) у нас есть новый нестандартный календарь под наши задачи.
Я попытался изобразить нечто похожее на базе GregorianCalendar — как самом близком к тому, что мы хотим.
Получилась вот такая (увы!) не полная и очень костыльная реализация.
Идея там примитивная:
— если нам приходит дата 29.02.1900 мы переводим её в валидную для григорианского календаря 1.03.1900,
— а затем, когда выводим обратно, учитываем эту поправку, т.е. проверяем: если дата перевалила за границу 1.03.1900 — то вычитаем 1 день, а иначе оставляем как есть (ну и конкретно с днем еще есть явная проверка, что если это 1.03.1900, то день в месяце — 29)
public class BadCalendar : GregorianCalendar
{
static DateTime TresholdDateTime = (new DateTime(1900, 3, 1)).AddTicks(-1);
public override DateTime ToDateTime(int year, int month, int day, int hour,
int minute, int second, int millisecond, int era)
{
if (year == 1900 && month == 2 && day == 29)
return base.ToDateTime(year, month, 28, hour, minute,
second, millisecond, era).AddDays(1);
return base.ToDateTime(year, month, day, hour, minute,
second, millisecond, era);
}
public override int GetMonth(DateTime time)
{
if (time > TresholdDateTime)
return base.GetMonth(time.AddDays(-1));
return base.GetMonth(time);
}
public override int GetYear(DateTime time)
{
if (time > TresholdDateTime)
return base.GetYear(time.AddDays(-1));
return base.GetYear(time);
}
public override int GetDayOfMonth(DateTime time)
{
if (time.Year == 1900 && time.Month == 3 && time.Day == 1)
return 29;
if (time > TresholdDateTime)
{
return base.GetDayOfMonth(time.AddDays(-1));
}
return base.GetDayOfMonth(time);
}
}
Благодаря этому костылю, я могу делать вот такое:
var calendar = new BadCalendar();
var formatInfo = new DateTimeFormatInfo();
var calendarField = formatInfo.GetType().GetField(
"calendar",
System.Reflection.BindingFlags.NonPublic
| System.Reflection.BindingFlags.Instance);
calendarField.SetValue(formatInfo, calendar);
var date = new DateTime(1900, 2, 29, calendar);
Console.WriteLine(date.ToString(formatInfo));
Теперь о не очень радостных моментах этого решения
— я не стал переопределять никакую арифметику на датах, прикинув "на пальцах", что той поправки, которую я описал должно хватить. Но я в этом не до конца уверен. А вот реализовывать все операции, мне очень не хочется (можете глянуть для примера как это сделано можно глянуть на метод AddMonths)
— есть проблемы с парсингом даты из строки, т.е. если к последнему коду добавить строчку
var date1 = DateTime.Parse("02/29/1900", formatInfo);
она вернет ошибку
System.FormatException: String '02/29/1900' was not recognized as a valid DateTime.
В общем, там проблема в том, что метод парсинга после того, как разобрал строку с датой передает все распарсенные элементы календарю для проверки и создания DateTime, и вызывает для этого метод TryToDateTime, который, вызывает ToDateTime, который мы реализовали...
Но (!) в дочернем GregorianCalendar этот метод переопределен и вызывает создание из DateTime... которая ничего про отличные от грегорианского календари не знает Печаль.
Я попытался обойти это разными способами:
— думал переопределять юлианский календарь, но там и так дата 29.02.1900 является допустимой (но там вся арифметика иная, а значит нужно сидеть и аккуратно переделывать всё).
— хотел уже плюнуть и, скопипастив код арифметики (и немного подправив её) сделать просто наследник от Calendar, но там всё упирается в свойство ID которое объявлено вот так:
internal virtual CalendarId ID => CalendarId.UNINITIALIZED_VALUE;
internal enum CalendarId : ushort
{
UNINITIALIZED_VALUE = 0,
GREGORIAN = 1, // Gregorian (localized) calendar
GREGORIAN_US = 2, // Gregorian (U.S.) calendar
JAPAN = 3, // Japanese Emperor Era calendar
/* SSS_WARNINGS_OFF */
TAIWAN = 4, // Taiwan Era calendar /* SSS_WARNINGS_ON */
KOREA = 5, // Korean Tangun Era calendar
HIJRI = 6, // Hijri (Arabic Lunar) calendar
THAI = 7, // Thai calendar
HEBREW = 8, // Hebrew (Lunar) calendar
GREGORIAN_ME_FRENCH = 9, // Gregorian Middle East French calendar
GREGORIAN_ARABIC = 10, // Gregorian Arabic calendar
GREGORIAN_XLIT_ENGLISH = 11, // Gregorian Transliterated English calendar
GREGORIAN_XLIT_FRENCH = 12,
// Note that all calendars after this point are MANAGED ONLY for now.
JULIAN = 13,
JAPANESELUNISOLAR = 14,
CHINESELUNISOLAR = 15,
SAKA = 16, // reserved to match Office but not implemented in our code
LUNAR_ETO_CHN = 17, // reserved to match Office but not implemented in our code
LUNAR_ETO_KOR = 18, // reserved to match Office but not implemented in our code
LUNAR_ETO_ROKUYOU = 19, // reserved to match Office but not implemented in our code
KOREANLUNISOLAR = 20,
TAIWANLUNISOLAR = 21,
PERSIAN = 22,
UMALQURA = 23,
LAST_CALENDAR = 23 // Last calendar ID
}
Как видите, этот идентификатор хоть и виртуальный, но объявлен internal, т.е. без рефлексии не подменить.
А используется он для извлечения системных ресурсов, связанных с датой и временем (например, названиями дней и месяцев) которые и используются при парсинге.
В общем, на дальнейшие изыскания у меня просто не хватило времени и моральных сил.
Но, надеюсь хоть чем-то смог вам помочь.