ExcelDateUtils.java

package com.github.mygreen.cellformatter.lang;

import java.math.BigDecimal;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.TimeZone;
import java.util.concurrent.TimeUnit;

/**
 * Excelの日時表現を処理するためのユーティリティクラス。
 * <ul>
 *  <li>Excel上は、1900年は閏年扱いで、1900年2月29日が存在する。</li>
 *  <li>Javaの形式に変換したときは、1900年3月1日扱いとする。</li>
 * </ul>
 *
 * @since 0.6
 * @author T.TSUCHIE
 *
 */
public class ExcelDateUtils {
    
    /**
     * 24時間の秒数。
     */
    public static final int SECONDS_IN_DAYS = (int) TimeUnit.HOURS.toSeconds(24);
    
    /**
     * {@literal 1900-01-01 00:00:00.000}の時間(単位はミリ秒)。
     * <p>Excelは設定により、1900年始まりか1904年始まりか指定できるため、その基準値として利用する。
     */
    public static final long MILLISECONDS_19000101 = parseDate("1900-01-01 00:00:00.000").getTime();
    
    /**
     * {@literal 1904-01-01 00:00:00.000}の時間(単位はミリ秒)。
     * <p>Excelは設定により、1900年始まりか1904年始まりか指定できるため、その基準値として利用する。
     */
    public static final long MILLISECONDS_19040101 = parseDate("1904-01-01 00:00:00.000").getTime();
    
    /**
     * 1900-03-01の時間(単位はミリ秒)。
     * <p>Excelは1900年始まりの場合、閏日でない1900年2月29日(=3月1日)が存在するため、その基準値として利用する。
     */
    public static final long MILLISECONDS_19000301 = ExcelDateUtils.parseDate("1900-03-01 00:00:00.000").getTime();
    
    /**
     * Javaの基準日(=0)である1970年1月1日に対するExcelの基準日1900年1月0日の日のオフセット(単位は日)。
     * <p>ただし、Excelは1900年は1月0日から始まり、実質1899年12月31日となる。
     */
    public static final int OFFSET_DAYS_1900; 
    
    /**
     * Javaの基準日(=0)である1970年1月1日に対するExcelの基準日1904年1月1日の日のオフセット(単位は日)。
     */
    public static final int OFFSET_DAYS_1904;
    
    static {
        long offsetDay1900 = parseDate("1900-01-01 00:00:00.000").getTime() / ((long)SECONDS_IN_DAYS * 1000);
        OFFSET_DAYS_1900 = (int) offsetDay1900 -1;
        
        long offsetDay1904 = parseDate("1904-01-01 00:00:00.000").getTime() / ((long)SECONDS_IN_DAYS * 1000);
        OFFSET_DAYS_1904 = (int) offsetDay1904;
    }
    
    /**
     * 1900年開始の場合、3月1日の経過日数。
     * <p>1900年は閏年ではないが、Excelの場合は閏年扱いのため、
     *    1900年1月~2月の期間(60日23時59分59秒=60.9999)を表現するための定数として利用する。
     */
    public static final int NON_LEAP_DAY = 61;
    
    /**
     * Excel表現上の数値をJavaの{@link Date}型(UTC形式)に変換する。
     * <p>1900年始まりの場合は以下の注意が必要。</p>
     * <ul>
     *   <li>値{@literal 0.0}は、Excel上は1900年1月0日であるが、Date型へ変換した場合は1899年12月31日となります。</li>
     *   <li>値{@literal 60.0}は、Excel上は1900年2月29日だが、グレゴリオ歴上は閏日ではあにため、1900年3月1日となります。</li>
     * </ul>
     * 
     * @param numValue 変換対象のExcel表現上の数値。
     * @param startDate1904 基準日が1904年始まりかどうか。
     * @return Java表現上に変換した日時。
     *         ただし、この値はタイムゾーンは考慮されていない(=GMT-00:00)ため、
     *         変換後は独自に処理を行う必要があります。
     */
    public static Date convertJavaDate(final double numValue, final boolean startDate1904) {
        
        double utcDay;
        if(startDate1904) {
            // 1904年始まりの場合
            
            // UTC(1970年基準に戻す)
            utcDay = numValue + OFFSET_DAYS_1904;
            
        } else {
            // 1900年始まりの場合
            
            // UTC(1970年基準に戻す)
            utcDay = numValue + OFFSET_DAYS_1900;
            
            /*
             * 1900年3月1日(Excel上は1900年2月29日)以降の場合の補正。
             * ・Excel上は1900年は、閏年扱いで1900年2月29日の閏年が存在する。
             * ・しかし、グレゴリオ歴上は1900年は閏年ではないため、UTCの表現上から見ると1日多い。
             * ・1900年2月29日は、1900年1月0日から数えて61日目なので、61日以降は、1日前に戻す。
             */
            if(numValue >= NON_LEAP_DAY) {
                utcDay -= 1;
            }
            
        }
        
        /*
         * Javaのミリ秒に直す。
         * ・Excelの日付の形式の場合小数部が時間を示すため、24時間分のミリ秒を考慮する。
         */
        long utcTime = Math.round(utcDay * SECONDS_IN_DAYS) * 1000;
        return new Date(utcTime);
        
    }
    
    /**
     * Javaの{@link Date}型をExcelの内部表現の数値に変換する。
     * <p>小数の桁数に関する注意事項。</p>
     * <ul>
     *    <li>このメソッドは少数は第16位まで保証し、小数第17位は四捨五入して計算します。</li>
     *    <li>Excelの1秒は、UTC上では1/(60x60x24x1000)=0.0000000115741=1.15741e-008であるので、小数13位までの精度が必要。</li>
     *    <li>Excelは秒までだが、Javaはミリ秒まで存在するので、さらに3桁多い、16桁までの精度が必要になる。</li>
     * </ul>
     * 
     * <p>1900年始まりの場合は以下の注意が必要。</p>
     * <ul>
     *   <li>UTC上は1900年2月29日は存在しないため、{@literal 60.0}への変換はできません。</li>
     * </ul>
     * 
     * @param value 変換対象のJava表現上の日時。タイムゾーンを排除した(GMT-00:00)日時。
     * @param startDate1904 基準日が1904年始まりかどうか。
     * @return Excel表現上に変換した数値。
     * @throws IllegalArgumentException {@literal value == nulll.}
     */
    public static double convertExcelNumber(final Date value, final boolean startDate1904) {
        
        ArgUtils.notNull(value, "value");
        
        /*
         * Excelの時間の表現に直す。
         * ・Excelの日付の形式の場合小数部が時間を示すため、24時間分のミリ秒を考慮する。
         */
        long utcDay = value.getTime();
        BigDecimal numValue = new BigDecimal(utcDay);
        numValue = numValue.divide(new BigDecimal(SECONDS_IN_DAYS * 1000), 17, BigDecimal.ROUND_HALF_UP);
        
        if(startDate1904) {
            // 1904年始まりの場合
            numValue = numValue.subtract(new BigDecimal(OFFSET_DAYS_1904));
            
        } else {
            // 1900年始まりの場合
            numValue = numValue.subtract(new BigDecimal(OFFSET_DAYS_1900));
            
            if(numValue.compareTo(new BigDecimal(NON_LEAP_DAY - 1)) >= 0) {
                numValue = numValue.add(new BigDecimal(1));
            }
            
        }
        
        return numValue.doubleValue();
        
    }
    
    /**
     * 日時形式を{@literal yyyy-MM-dd HH:mm:ss.SSS}の書式でフォーマットする。
     * <p>ただし、タイムゾーンは、標準時間の{@literal GMT-00:00}で処理する。
     * @param date フォーマット対象の日時。
     * @return フォーマットした文字列。
     * @throws IllegalArgumentException date is null.
     */
    public static String formatDate(final Date date) {
        
        ArgUtils.notNull(date, "date");
        
        SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
        format.setTimeZone(TimeZone.getTimeZone("GMT-00:00"));
        return format.format(date);
        
    }
    
    /**
     * 文字列を日時形式を{@literal yyyy-MM-dd HH:mm:ss.SSS}のパースする。
     * <p>ただし、タイムゾーンは、標準時間の{@literal GMT-00:00}で処理する。
     * @param str パース対象の文字列
     * @return パースした日付。
     * @throws IllegalArgumentException str is empty.
     * @throws IllegalStateException fail parsing.
     */
    public static Date parseDate(final String str) {
        ArgUtils.notEmpty(str, str);
        
        try {
            SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
            format.setTimeZone(TimeZone.getTimeZone("GMT-00:00"));
            return format.parse(str);
        } catch (ParseException e) {
            throw new IllegalStateException(String.format("fail parse to Data from '%s',", str), e);
        }
        
    }
    
    /**
     * Excelの日付となる(=数値では0の値)の時の、時間の取得。(単位はミリ秒)。
     * <p>1900年始まりの場合、Excelでは1月0日から始まるため、{@literal 1899-12-31 00:00:00.000}の値を返す。
     * <p>1904年始まりは、{@literal 1904-01-01 0:00:00}の値を返す。
     * <p>引数により、1904年始まりの場合の値か選択できる。
     * @param isStartDate1904 1904年始まりかどうか。
     * @return Excelの基準日に対するUTC表現上のミリ秒。
     */
    public static long getExcelZeroDateTime(boolean isStartDate1904) {
        if(isStartDate1904) {
            return MILLISECONDS_19040101;
        } else {
            return MILLISECONDS_19000101 - TimeUnit.DAYS.toMillis(1);
        }
    }
    
}