$birthdays * @var Collection|null $anniversaries * @var Collection|null $historical * @var bool $include_anniversaries * @var bool $include_historical * @var int $lookahead_days * @var int $historical_lookahead * @var int $generated_at * @var array $avatar_cids xref => CID name * @var string $account_url */ // ─── Aesthetic constants ──────────────────────────────────────────────── $palette = [ 'paper' => '#f8f1e3', // warm ivory background 'ink' => '#3a2820', // deep oxblood-brown body text 'gold' => '#a17536', // aged-gold accents 'rule' => '#d4be91', // hairline rule 'mute' => '#7a6a5e', // muted secondary text 'shadow' => '#5a4a3e', // for darker symbols (death) ]; $serif_display = "'EB Garamond', Georgia, 'Times New Roman', serif"; $serif_body = "Georgia, 'Iowan Old Style', 'Palatino Linotype', serif"; $avatar_size = 56; // ─── Helpers ──────────────────────────────────────────────────────────── $linked_name = static function (Individual $individual): string { $name = strip_tags($individual->fullName()); $url = $individual->url(); $style = 'color:inherit;text-decoration:none;border-bottom:1px solid #c8a96a;'; return '' . e($name) . ''; }; $record_label = static function (Fact $fact) use ($linked_name): string { $record = $fact->record(); if ($record instanceof Individual) { return $linked_name($record); } if ($record instanceof Family) { $parts = []; foreach ([$record->husband(), $record->wife()] as $spouse) { if ($spouse instanceof Individual) { $parts[] = $linked_name($spouse); } } return implode(' & ', $parts); } return e($record->xref()); }; $event_date_display = static function (Fact $fact): string { $date = $fact->date(); if (!$date instanceof Date || !$date->isOK()) { return ''; } return strip_tags($date->display()); }; $event_kind = static function (Fact $fact): string { $tag = $fact->tag(); $parts = explode(':', $tag); return end($parts); }; /** * Compact inline SVG glyph for each event type. Rendered in modern * webmail (Gmail web, Apple Mail, iOS, Outlook 365); Outlook desktop * strips SVG silently — the textual event label still carries the * meaning, so the email reads correctly without it. */ $event_icon = static function (string $kind) use ($palette): string { $svg_open = ' $svg_open . 'viewBox="0 0 24 24" width="18" height="18">' . '' . '', // Latin obelus/dagger — the typographic convention for "died" in // obituaries since the 18th century. Restrained, narrow. 'DEAT' => $svg_open . 'viewBox="0 0 24 24" width="14" height="18">' . '' . '' . '', // Two interlocking rings — universal heraldic mark of marriage. 'MARR' => $svg_open . 'viewBox="0 0 36 22" width="28" height="18">' . '' . '' . '', default => '', }; }; /** * A single circular avatar — either the embedded image, or a coloured * disc with the person's initials. Wraps in an linking to the * individual's webtrees page when available. */ $avatar = static function (Individual|null $individual) use ($avatar_cids, $avatar_size, $palette): string { if (!$individual instanceof Individual) { return ''; } $alt = e(strip_tags($individual->fullName())); $shadow = 'box-shadow:0 0 0 1px ' . $palette['rule'] . ',0 2px 6px rgba(58,40,32,0.18);'; if (isset($avatar_cids[$individual->xref()])) { $cid = $avatar_cids[$individual->xref()]; $inner = '' . $alt . ''; } else { $hue = hexdec(substr(md5($individual->xref()), 0, 2)) * 360 / 255; $first = strip_tags($individual->getAllNames()[0]['givn'] ?? $individual->xref()); $last = strip_tags($individual->getAllNames()[0]['surn'] ?? ''); $initials = e(mb_strtoupper(mb_substr($first, 0, 1) . mb_substr($last, 0, 1))); $inner = '' . $initials . ''; } return '' . $inner . ''; }; $record_avatars = static function (Fact $fact) use ($avatar): string { $record = $fact->record(); if ($record instanceof Individual) { return $avatar($record); } if ($record instanceof Family) { $parts = []; foreach ([$record->husband(), $record->wife()] as $spouse) { if ($spouse instanceof Individual) { $parts[] = $avatar($spouse); } } // Two circles overlapping by a few px reads as "couple" without // needing additional glue characters. return '' . '' . '' . (isset($parts[1]) ? '' : '') . '
' . ($parts[0] ?? '') . '' . $parts[1] . '
'; } return ''; }; // Locale-aware ordinal: English uses st/nd/rd/th; German just appends ".". $ordinal = static function (int $n): string { if (str_starts_with(I18N::languageTag(), 'de')) { return $n . '.'; } $abs = abs($n); $mod100 = $abs % 100; if ($mod100 >= 11 && $mod100 <= 13) { return $n . 'th'; } return $n . match ($abs % 10) { 1 => 'st', 2 => 'nd', 3 => 'rd', default => 'th', }; }; $upcoming_age = static function (Fact $fact): int { static $gregorian = null; $gregorian ??= new \Fisharebest\ExtCalendar\GregorianCalendar(); $date = $fact->date(); if (!$date->isOK()) { return 0; } $event_year = $date->gregorianYear(); $upcoming_jd = $fact->jd ?? 0; if ($upcoming_jd > 0) { [$upcoming_year] = $gregorian->jdToYmd($upcoming_jd); } else { $upcoming_year = (int) date('Y'); } return max(0, $upcoming_year - $event_year); }; $birthday_label = static function (int $age) use ($ordinal): string { return $age > 0 ? I18N::translate('%s birthday', $ordinal($age)) : I18N::translate('Birthday'); }; $anniversary_label = static function (int $age) use ($ordinal): string { return $age > 0 ? I18N::translate('%s wedding anniversary', $ordinal($age)) : I18N::translate('Wedding anniversary'); }; // Styles reused across the event lists. $row_padding = '20px 0'; $divider_style = 'border-bottom:1px solid ' . $palette['rule'] . ';'; $section_title_style = 'margin:48px 0 8px;' . 'font-family:' . $serif_display . ';' . 'font-weight:500;font-size:28px;line-height:1.1;' . 'color:' . $palette['ink'] . ';' . 'letter-spacing:0.005em;'; $section_kicker_style = 'margin:0 0 22px;' . 'font-family:' . $serif_display . ';' . 'font-style:italic;font-size:15px;' . 'color:' . $palette['mute'] . ';' . 'letter-spacing:0.02em;'; $timeline_cell_style = 'width:170px;vertical-align:top;' . 'padding:24px 0 24px 28px;' . 'border-left:1px solid ' . $palette['rule'] . ';' . 'font-family:' . $serif_body . ';' . 'color:' . $palette['mute'] . ';' . 'font-size:12px;letter-spacing:0.12em;text-transform:uppercase;' . 'white-space:nowrap;'; $timeline_dot_style = 'display:inline-block;' . 'width:11px;height:11px;background:' . $palette['gold'] . ';' . 'border-radius:50%;' . 'margin-left:-34px;margin-right:14px;vertical-align:middle;'; $content_cell_style = 'vertical-align:middle;' . 'padding:24px 24px 24px 18px;' . 'font-family:' . $serif_body . ';font-size:16px;line-height:1.4;' . 'color:' . $palette['ink'] . ';'; $avatar_cell_style = 'width:64px;vertical-align:middle;padding:18px 0;'; // Renders one event row in the consistent 3-column layout shared by all // sections (avatar | content | timeline + date). /** * Locale-aware long-form date for the masthead. German edition reads * "15. Mai 2026" rather than the English "May 15, 2026". */ $masthead_date = static function (int $timestamp): string { if (str_starts_with(I18N::languageTag(), 'de')) { $months = [ 1 => 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember', ]; return (int) date('j', $timestamp) . '. ' . $months[(int) date('n', $timestamp)] . ' ' . date('Y', $timestamp); } return date('F j, Y', $timestamp); }; $event_row = static function (Fact $fact, string $body_html) use ( $record_avatars, $event_date_display, $avatar_cell_style, $content_cell_style, $timeline_cell_style, $timeline_dot_style, $divider_style, ): string { return '' . '' . $record_avatars($fact) . '' . '' . $body_html . '' . '' . '' . e($event_date_display($fact)) . '' . ''; }; ?> <?= e(I18N::translate('Family newsletter — %s', date('F j, Y', $generated_at))) ?>
isEmpty()) : ?> isEmpty()) : ?> isEmpty()) : ?>

title()) ?>

' . '' . $record_label($fact) . '' . ' — ' . e($birthday_label($age)) . '' . ''; echo $event_row($fact, $body); ?>

' . '' . $record_label($fact) . '' . ' — ' . e($anniversary_label($age)) . '' . ''; echo $event_row($fact, $body); ?>

' . '' . $record_label($fact) . '' . ' — ' . e($fact->label()) . '' . ''; echo $event_row($fact, $body); ?>

title())) ?>
' . e(I18N::translate('My account')) . '', ) ?>