JewishCalendar::yearToHebrewNumerals()   A
last analyzed

Complexity

Conditions 4
Paths 5

Size

Total Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
nc 5
nop 4
dl 0
loc 16
rs 9.7333
c 0
b 0
f 0
1
<?php
2
namespace Fisharebest\ExtCalendar;
3
4
use InvalidArgumentException;
5
6
/**
7
 * Class JewishCalendar - calculations for the Jewish calendar.
8
 *
9
 * Hebrew characters in the code have either ISO-8859-8 or UTF_8 encoding.
10
 * Hebrew characters in the comments have UTF-8 encoding.
11
 *
12
 * @author    Greg Roach <[email protected]>
13
 * @copyright (c) 2014-2020 Greg Roach
14
 * @license   This program is free software: you can redistribute it and/or modify
15
 *            it under the terms of the GNU General Public License as published by
16
 *            the Free Software Foundation, either version 3 of the License, or
17
 *            (at your option) any later version.
18
 *
19
 *            This program is distributed in the hope that it will be useful,
20
 *            but WITHOUT ANY WARRANTY; without even the implied warranty of
21
 *            MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
22
 *            GNU General Public License for more details.
23
 *
24
 *            You should have received a copy of the GNU General Public License
25
 *            along with this program.  If not, see <http://www.gnu.org/licenses/>.
26
 */
27
class JewishCalendar implements CalendarInterface
28
{
29
    /** Optional behaviour for this calendar. */
30
    const EMULATE_BUG_54254 = 'EMULATE_BUG_54254';
31
32
    /** Place this symbol before the final letter of a sequence of numerals */
33
    const GERSHAYIM_ISO8859 = '"';
34
    const GERSHAYIM         = "\xd7\xb4";
35
36
    /** Place this symbol after a single numeral */
37
    const GERESH_ISO8859 = '\'';
38
    const GERESH         = "\xd7\xb3";
39
40
    /** The Hebrew word for thousand */
41
    const ALAFIM_ISO8859 = "\xe0\xec\xf4\xe9\xed";
42
    const ALAFIM         = "\xd7\x90\xd7\x9c\xd7\xa4\xd7\x99\xd7\x9d";
43
44
    /** A year that is one day shorter than normal. */
45
    const DEFECTIVE_YEAR = -1;
46
47
    /** A year that has the normal number of days. */
48
    const REGULAR_YEAR = 0;
49
50
    /** A year that is one day longer than normal. */
51
    const COMPLETE_YEAR = 1;
52
53
    /** @var string[] Hebrew numbers are represented by letters, similar to roman numerals. */
54
    private static $HEBREW_NUMERALS_ISO8859_8 = array(
55
        400 => "\xfa",
56
        300 => "\xf9",
57
        200 => "\xf8",
58
        100 => "\xf7",
59
        90  => "\xf6",
60
        80  => "\xf4",
61
        70  => "\xf2",
62
        60  => "\xf1",
63
        50  => "\xf0",
64
        40  => "\xee",
65
        30  => "\xec",
66
        20  => "\xeb",
67
        19  => "\xe9\xe8",
68
        18  => "\xe9\xe7",
69
        17  => "\xe9\xe6",
70
        16  => "\xe8\xe6",
71
        15  => "\xe8\xe5",
72
        10  => "\xe9",
73
        9   => "\xe8",
74
        8   => "\xe7",
75
        7   => "\xe6",
76
        6   => "\xe5",
77
        5   => "\xe4",
78
        4   => "\xe3",
79
        3   => "\xe2",
80
        2   => "\xe1",
81
        1   => "\xe0",
82
    );
83
84
    /** @var string[] Hebrew numbers are represented by letters, similar to roman numerals. */
85
    private static $HEBREW_NUMERALS_UTF8 = array(
86
        400 => "\xd7\xaa",
87
        300 => "\xd7\xa9",
88
        200 => "\xd7\xa8",
89
        100 => "\xd7\xa7",
90
        90  => "\xd7\xa6",
91
        80  => "\xd7\xa4",
92
        70  => "\xd7\xa2",
93
        60  => "\xd7\xa1",
94
        50  => "\xd7\xa0",
95
        40  => "\xd7\x9e",
96
        30  => "\xd7\x9c",
97
        20  => "\xd7\x9b",
98
        19  => "\xd7\x99\xd7\x98",
99
        18  => "\xd7\x99\xd7\x97",
100
        17  => "\xd7\x99\xd7\x96",
101
        16  => "\xd7\x98\xd7\x96",
102
        15  => "\xd7\x98\xd7\x95",
103
        10  => "\xd7\x99",
104
        9   => "\xd7\x98",
105
        8   => "\xd7\x97",
106
        7   => "\xd7\x96",
107
        6   => "\xd7\x95",
108
        5   => "\xd7\x94",
109
        4   => "\xd7\x93",
110
        3   => "\xd7\x92",
111
        2   => "\xd7\x91",
112
        1   => "\xd7\x90",
113
    );
114
115
    /** @var string[] Some letters have a different final form  */
116
    private static $FINAL_FORMS_UTF8 = array(
117
        "\xd7\x9b" => "\xd7\x9a",
118
        "\xd7\x9e" => "\xd7\x9d",
119
        "\xd7\xa0" => "\xd7\x9f",
120
        "\xd7\xa4" => "\xd7\xa3",
121
        "\xd7\xa6" => "\xd7\xa5",
122
    );
123
124
    /** @var int[] These months have fixed lengths.  Others are variable. */
125
    private static $FIXED_MONTH_LENGTHS = array(
126
        1 => 30, 4 => 29, 5 => 30, 7 => 29, 8 => 30, 9 => 29, 10 => 30, 11 => 29, 12 => 30, 13 => 29
127
    );
128
129
    /**
130
     * Cumulative number of days for each month in each type of year.
131
     * First index is false/true (non-leap year, leap year)
132
     * Second index is year type (-1, 0, 1)
133
     * Third index is month number (1 ... 13)
134
     *
135
     * @var int[][][]
136
     */
137
    private static $CUMULATIVE_DAYS = array(
138
        0 => array( // Non-leap years
139
            self::DEFECTIVE_YEAR => array(
140
                1 => 0, 30, 59, 88, 117, 147, 147, 176, 206, 235, 265, 294, 324
141
            ),
142
            self::REGULAR_YEAR  => array( // Regular years
143
                1 => 0, 30, 59, 89, 118, 148, 148, 177, 207, 236, 266, 295, 325
144
            ),
145
            self::COMPLETE_YEAR  => array( // Complete years
146
                1 => 0, 30, 60, 90, 119, 149, 149, 178, 208, 237, 267, 296, 326
147
            ),
148
        ),
149
        1 => array( // Leap years
150
            self::DEFECTIVE_YEAR => array( // Deficient years
151
                1 => 0, 30, 59, 88, 117, 147, 177, 206, 236, 265, 295, 324, 354
152
            ),
153
            self::REGULAR_YEAR  => array( // Regular years
154
                1 => 0, 30, 59, 89, 118, 148, 178, 207, 237, 266, 296, 325, 355
155
            ),
156
            self::COMPLETE_YEAR  => array( // Complete years
157
                1 => 0, 30, 60, 90, 119, 149, 179, 208, 238, 267, 297, 326, 356
158
            ),
159
        ),
160
    );
161
162
    /** @var int[] Rosh Hashanah cannot fall on a Sunday, Wednesday or Friday.  Move the year start accordingly. */
163
    private static $ROSH_HASHANAH = array(347998, 347997, 347997, 347998, 347997, 347998, 347997);
164
165
    /** @var mixed[] special behaviour for this calendar */
166
    protected $options = array(
167
        self::EMULATE_BUG_54254 => false,
168
    );
169
170
    /**
171
     * @param mixed[] $options Some calendars have options that change their behaviour.
172
     */
173
    public function __construct($options = array())
174
    {
175
        $this->options = array_merge($this->options, $options);
176
    }
177
178
    /**
179
     * Determine the number of days in a specified month, allowing for leap years, etc.
180
     *
181
     * @param int $year
182
     * @param int $month
183
     *
184
     * @return int
185
     */
186
    public function daysInMonth($year, $month)
187
    {
188
        if ($year < 1) {
189
            throw new InvalidArgumentException('Year ' . $year . ' is invalid for this calendar');
190 View Code Duplication
        } elseif ($month < 1 || $month > 13) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
191
            throw new InvalidArgumentException('Month ' . $month . ' is invalid for this calendar');
192
        } elseif ($month == 2) {
193
            return $this->daysInMonthHeshvan($year);
194
        } elseif ($month == 3) {
195
            return $this->daysInMonthKislev($year);
196
        } elseif ($month == 6) {
197
            return $this->daysInMonthAdarI($year);
198
        } else {
199
            return self::$FIXED_MONTH_LENGTHS[$month];
200
        }
201
    }
202
203
    /**
204
     * Determine the number of days in a week.
205
     *
206
     * @return int
207
     */
208
    public function daysInWeek()
209
    {
210
        return 7;
211
    }
212
213
    /**
214
     * The escape sequence used to indicate this calendar in GEDCOM files.
215
     *
216
     * @return string
217
     */
218
    public function gedcomCalendarEscape()
219
    {
220
        return '@#DHEBREW@';
221
    }
222
223
    /**
224
     * Determine whether or not a given year is a leap-year.
225
     *
226
     * @param int $year
227
     *
228
     * @return bool
229
     */
230
    public function isLeapYear($year)
231
    {
232
        return (7 * $year + 1) % 19 < 7;
233
    }
234
235
    /**
236
     * What is the highest Julian day number that can be converted into this calendar.
237
     *
238
     * @return int
239
     */
240
    public function jdEnd()
241
    {
242
        return PHP_INT_MAX;
243
    }
244
245
    /**
246
     * What is the lowest Julian day number that can be converted into this calendar.
247
     *
248
     * @return int
249
     */
250
    public function jdStart()
251
    {
252
        return 347998; // 1 Tishri 0001 AM
253
    }
254
255
    /**
256
     * Convert a Julian day number into a year.
257
     *
258
     * @param int $julian_day
259
     *
260
     * @return int
261
     */
262
    protected function jdToY($julian_day)
263
    {
264
        // Estimate the year, and underestimate it, it will be refined after
265
        $year = max((int) ((($julian_day - 347998) * 98496) / 35975351) - 1, 1);
266
267
        // Adjust by adding years;
268
        while ($julian_day >= $this->yToJd($year + 1)) {
269
            $year++;
270
        }
271
272
        return $year;
273
    }
274
275
    /**
276
     * Convert a Julian day number into a year/month/day.
277
     *
278
     * @param int $julian_day
279
     *
280
     * @return int[]
281
     */
282
    public function jdToYmd($julian_day)
283
    {
284
        // Find the year, by adding one month at a time to use up the remaining days.
285
        $year  = $this->jdToY($julian_day);
286
        $month = 1;
287
        $day   = $julian_day - $this->yToJd($year) + 1;
288
289
        while ($day > $this->daysInMonth($year, $month)) {
290
            $day -= $this->daysInMonth($year, $month);
291
            $month++;
292
        }
293
294
        // PHP 5.4 and earlier converted non leap-year Adar into month 6, instead of month 7.
295
        $month -= ($month === 7 && $this->options[self::EMULATE_BUG_54254] && !$this->isLeapYear($year)) ? 1 : 0;
296
297
        return array($year, $month, $day);
298
    }
299
300
    /**
301
     * Determine the number of months in a year (if given),
302
     * or the maximum number of months in any year.
303
     *
304
     * @param int|null $year
305
     *
306
     * @return int
307
     */
308
    public function monthsInYear($year = null)
309
    {
310
        if ($year !== null && !$this->isLeapYear($year)) {
311
            return 12;
312
        }
313
314
        return 13;
315
    }
316
317
    /**
318
     * Calculate the Julian Day number of the first day in a year.
319
     *
320
     * @param int $year
321
     *
322
     * @return int
323
     */
324
    protected function yToJd($year)
325
    {
326
        $div19 = (int) (($year - 1) / 19);
327
        $mod19 = ($year - 1) % 19;
328
329
        $months      = 235 * $div19 + 12 * $mod19 + (int) ((7 * $mod19 + 1) / 19);
330
        $parts       = 204 + 793 * ($months % 1080);
331
        $hours       = 5 + 12 * $months + 793 * (int) ($months / 1080) + (int) ($parts / 1080);
332
        $conjunction = 1080 * ($hours % 24) + ($parts % 1080);
333
        $julian_day  = 1 + 29 * $months + (int) ($hours / 24);
334
335
        if ($conjunction >= 19440 ||
336
            $julian_day % 7 === 2 && $conjunction >= 9924 && !$this->isLeapYear($year) ||
337
            $julian_day % 7 === 1 && $conjunction >= 16789 && $this->isLeapYear($year - 1)
338
        ) {
339
            $julian_day++;
340
        }
341
342
        // The actual year start depends on the day of the week
343
        return $julian_day + self::$ROSH_HASHANAH[$julian_day % 7];
344
    }
345
346
    /**
347
     * Convert a year/month/day to a Julian day number.
348
     *
349
     * @param int $year
350
     * @param int $month
351
     * @param int $day
352
     *
353
     * @return int
354
     */
355
    public function ymdToJd($year, $month, $day)
356
    {
357
        return
358
            $this->yToJd($year) +
359
            self::$CUMULATIVE_DAYS[$this->isLeapYear($year)][$this->yearType($year)][$month] +
360
            $day - 1;
361
    }
362
363
    /**
364
     * Determine whether a year is normal, defective or complete.
365
     *
366
     * @param int $year
367
     *
368
     * @return int defective (-1), normal (0) or complete (1)
369
     */
370
    private function yearType($year)
371
    {
372
        $year_length = $this->yToJd($year + 1) - $this->yToJd($year);
373
374
        if ($year_length === 353 || $year_length === 383) {
375
            return self::DEFECTIVE_YEAR;
376
        } elseif ($year_length === 355 || $year_length === 385) {
377
            return self::COMPLETE_YEAR;
378
        } else {
379
            return self::REGULAR_YEAR;
380
        }
381
    }
382
383
    /**
384
     * Calculate the number of days in Heshvan.
385
     *
386
     * @param int $year
387
     *
388
     * @return int
389
     */
390
    private function daysInMonthHeshvan($year)
391
    {
392
        if ($this->yearType($year) === self::COMPLETE_YEAR) {
393
            return 30;
394
        } else {
395
            return 29;
396
        }
397
    }
398
399
    /**
400
     * Calculate the number of days in Kislev.
401
     *
402
     * @param int $year
403
     *
404
     * @return int
405
     */
406
    private function daysInMonthKislev($year)
407
    {
408
        if ($this->yearType($year) === self::DEFECTIVE_YEAR) {
409
            return 29;
410
        } else {
411
            return 30;
412
        }
413
    }
414
415
    /**
416
     * Calculate the number of days in Adar I.
417
     *
418
     * @param int $year
419
     *
420
     * @return int
421
     */
422
    private function daysInMonthAdarI($year)
423
    {
424
        if ($this->isLeapYear($year)) {
425
            return 30;
426
        } else {
427
            return 0;
428
        }
429
    }
430
431
    /**
432
     * Hebrew month names.
433
     *
434
     * @link https://bugs.php.net/bug.php?id=54254
435
     *
436
     * @param int $year
437
     *
438
     * @return string[]
439
     */
440
    protected function hebrewMonthNames($year)
441
    {
442
        $leap_year = $this->isLeapYear($year);
443
444
        return array(
445
            1 => "\xfa\xf9\xf8\xe9", // Tishri - תשרי
446
            "\xe7\xf9\xe5\xef", // Heshvan - חשון
447
            "\xeb\xf1\xec\xe5", // Kislev - כסלו
448
            "\xe8\xe1\xfa", // Tevet - טבת
449
            "\xf9\xe1\xe8", // Shevat - שבט
450
            $leap_year ? ($this->options[self::EMULATE_BUG_54254] ? "\xe0\xe3\xf8" : "\xe0\xe3\xf8 \xe0'") : "\xe0\xe3\xf8", // Adar I - אדר - אדר א׳ - אדר
451
            $leap_year ? ($this->options[self::EMULATE_BUG_54254] ? "'\xe0\xe3\xf8 \xe1" : "\xe0\xe3\xf8 \xe1'") : "\xe0\xe3\xf8", // Adar II - 'אדר ב - אדר ב׳ - אדר
452
            "\xf0\xe9\xf1\xef", // Nisan - ניסן
453
            "\xe0\xe9\xe9\xf8", // Iyar - אייר
454
            "\xf1\xe9\xe5\xef", // Sivan - סיון
455
            "\xfa\xee\xe5\xe6", // Tammuz - תמוז
456
            "\xe0\xe1", // Av - אב
457
            "\xe0\xec\xe5\xec", // Elul - אלול
458
        );
459
    }
460
461
    /**
462
     * The Hebrew name of a given month.
463
     *
464
     * @param int $year
465
     * @param int $month
466
     *
467
     * @return string
468
     */
469
    protected function hebrewMonthName($year, $month)
470
    {
471
        $months = $this->hebrewMonthNames($year);
472
473
        return $months[$month];
474
    }
475
476
    /**
477
     * Add geresh (׳) and gershayim (״) punctuation to numeric values.
478
     *
479
     * Gereshayim is a contraction of “geresh” and “gershayim”.
480
     *
481
     * @param string $hebrew
482
     *
483
     * @return string
484
     */
485
    protected function addGereshayim($hebrew)
486
    {
487
        switch (strlen($hebrew)) {
488
            case 0:
489
                // Zero, e.g. the zeros from the year 5,000
490
                return $hebrew;
491
            case 1:
492
                // Single digit - append a geresh
493
                return $hebrew . self::GERESH_ISO8859;
494
            default:
495
                // Multiple digits - insert a gershayim
496
                return substr_replace($hebrew, self::GERSHAYIM_ISO8859, -1, 0);
497
        }
498
    }
499
500
    /**
501
     * Convert a number into a string, in the style of roman numerals
502
     *
503
     * @param int      $number
504
     * @param string[] $numerals
505
     *
506
     * @return string
507
     */
508
    private function numberToNumerals($number, array $numerals)
509
    {
510
        $string = '';
511
512
        while ($number > 0) {
513
            foreach ($numerals as $n => $t) {
514
                if ($number >= $n) {
515
                    $string .= $t;
516
                    $number -= $n;
517
                    break;
518
                }
519
            }
520
        }
521
522
        return $string;
523
    }
524
525
    /**
526
     * Convert a number into Hebrew numerals using UTF8.
527
     *
528
     * @param int  $number
529
     * @param bool $show_thousands
530
     *
531
     * @return string
532
     */
533
    public function numberToHebrewNumerals($number, $show_thousands)
534
    {
535
        // Years (e.g. "5782") may be written without the thousands (e.g. just "782"),
536
        // but since there is no zero, the number 5000 must be written as "5 thousand"
537
        if ($show_thousands || $number % 1000 === 0) {
538
            $thousands = (int)($number / 1000);
539
        } else {
540
            $thousands = 0;
541
        }
542
        $number = $number % 1000;
543
544
        $hebrew = $this->numberToNumerals($number, self::$HEBREW_NUMERALS_UTF8);
545
546
        // Two bytes per UTF8 character
547
        if (strlen($hebrew) === 2) {
548
            // Append a geresh after single-digit
549
            $hebrew .= self::GERESH;
550
        } elseif (strlen($hebrew) > 2) {
551
            // Some letters have a "final" form, when used at the end of a word.
552
            $hebrew = substr($hebrew, 0, -2) . strtr(substr($hebrew, -2), self::$FINAL_FORMS_UTF8);
553
            // Insert a gershayim before the final letter
554
            $hebrew = substr_replace($hebrew, self::GERSHAYIM, -2, 0);
555
        }
556
557
        if ($thousands) {
558
            if ($hebrew) {
559
                $hebrew = $this->numberToHebrewNumerals($thousands, $show_thousands) . $hebrew;
560
            } else {
561
                $hebrew = $this->numberToHebrewNumerals($thousands, $show_thousands) . ' ' . self::ALAFIM;
562
            }
563
        }
564
565
        return $hebrew;
566
    }
567
568
    /**
569
     * Convert a number into Hebrew numerals using ISO8859-8.
570
     *
571
     * @param int  $number
572
     * @param bool $gereshayim Add punctuation to numeric values
573
     *
574
     * @return string
575
     */
576
    protected function numberToHebrewNumeralsIso8859($number, $gereshayim)
577
    {
578
        $hebrew = $this->numberToNumerals($number, self::$HEBREW_NUMERALS_ISO8859_8);
579
580
        // Hebrew numerals are letters.  Add punctuation to prevent confusion with actual words.
581
        if ($gereshayim) {
582
            return $this->addGereshayim($hebrew);
583
        } else {
584
            return $hebrew;
585
        }
586
    }
587
588
    /**
589
     * Format a year using Hebrew numerals.
590
     *
591
     * @param int  $year
592
     * @param bool $alafim_geresh Add a geresh  (׳) after thousands
593
     * @param bool $alafim        Add the word for thousands after the thousands
594
     * @param bool $gereshayim    Add geresh (׳) and gershayim (״) punctuation to numeric values
595
     *
596
     * @return string
597
     */
598
    protected function yearToHebrewNumerals($year, $alafim_geresh, $alafim, $gereshayim)
599
    {
600
        if ($year < 1000) {
601
            return $this->numberToHebrewNumeralsIso8859($year, $gereshayim);
602
        } else {
603
            $thousands = $this->numberToHebrewNumeralsIso8859((int) ($year / 1000), false);
604
            if ($alafim_geresh) {
605
                $thousands .= self::GERESH_ISO8859;
606
            }
607
            if ($alafim) {
608
                $thousands .= ' ' . self::ALAFIM_ISO8859 . ' ';
609
            }
610
611
            return $thousands . $this->numberToHebrewNumeralsIso8859($year % 1000, $gereshayim);
612
        }
613
    }
614
615
    /**
616
     * Convert a Julian Day number into a Hebrew date.
617
     *
618
     * @param int  $julian_day
619
     * @param bool $alafim_garesh
620
     * @param bool $alafim
621
     * @param bool $gereshayim
622
     *
623
     * @return string
624
     */
625
    public function jdToHebrew($julian_day, $alafim_garesh, $alafim, $gereshayim)
626
    {
627
        list($year, $month, $day) = $this->jdToYmd($julian_day);
628
629
        return
630
            $this->numberToHebrewNumeralsIso8859($day, $gereshayim) . ' ' .
631
            $this->hebrewMonthName($year, $month) . ' ' .
632
            $this->yearToHebrewNumerals($year, $alafim_garesh, $alafim, $gereshayim);
633
    }
634
}
635