%% calendar-grid.sty -- deterministic calendar grids for LaTeX %% Copyright (C) 2026 Mateo Pedersen %% This work may be distributed and/or modified under the conditions of the %% LaTeX Project Public License, version 1.3c or (at your option) any later %% version. The latest version is at https://www.latex-project.org/lppl/. %% This work has LPPL maintenance status `maintained'. %% The Current Maintainer of this work is Mateo Pedersen. %% The maintainer can be contacted through the repository issue tracker: %% https://github.com/mateopedersen/calendar-grid-latex/issues %% This work consists of all files listed in manifest.txt. \NeedsTeXFormat{LaTeX2e}[2020-10-01] \ProvidesExplPackage{calendar-grid}{2026-10-06}{1.0.0} {Deterministic month and year calendar grids} \RequirePackage{array} \ExplSyntaxOn \msg_new:nnn { calendar-grid } { bad-date } { Invalid~Gregorian~date~'#1'.~Use~YYYY-MM-DD~from~0001-01-01~to~9999-12-31. } \msg_new:nnn { calendar-grid } { bad-month } { Month~'#1'~must~be~an~integer~from~1~to~12. } \msg_new:nnn { calendar-grid } { bad-year } { Year~'#1'~must~be~an~integer~from~1~to~9999. } \msg_new:nnn { calendar-grid } { bad-list } { The~'#1'~label~list~must~contain~exactly~#2~items. } \msg_new:nnn { calendar-grid } { bad-columns } { Year~overview~columns~must~be~an~integer~from~1~to~4. } \msg_new:nnn { calendar-grid } { reversed-range } { Range~start~date~'#1'~must~not~be~after~'#2'. } \int_new:N \l__cg_year_int \int_new:N \l__cg_month_int \int_new:N \l__cg_columns_int \int_new:N \l__cg_week_start_int \int_new:N \l__cg_month_days_int \int_new:N \l__cg_first_weekday_int \int_new:N \l__cg_offset_int \int_new:N \l__cg_rows_int \int_new:N \l__cg_cells_int \int_new:N \l__cg_index_int \int_new:N \l__cg_cell_year_int \int_new:N \l__cg_cell_month_int \int_new:N \l__cg_cell_day_int \int_new:N \l__cg_cell_weekday_int \int_new:N \l__cg_cell_row_int \int_new:N \l__cg_cell_column_int \int_new:N \l__cg_cell_serial_int \int_new:N \l__cg_serial_int \int_new:N \l__cg_date_year_int \int_new:N \l__cg_date_month_int \int_new:N \l__cg_date_day_int \int_new:N \l__cg_jdn_a_int \int_new:N \l__cg_jdn_y_int \int_new:N \l__cg_jdn_m_int \int_new:N \l__cg_jdn_int \int_new:N \l__cg_range_id_int \int_new:N \l__cg_range_start_int \int_new:N \l__cg_range_end_int \int_new:N \l__cg_range_counter_int \int_new:N \g__cg_range_id_int \bool_new:N \l__cg_leap_bool \bool_new:N \l__cg_cell_current_bool \bool_new:N \l__cg_cell_range_bool \bool_new:N \l__cg_cell_weekend_bool \bool_new:N \l__cg_date_valid_bool \dim_new:N \l__cg_cell_width_dim \dim_new:N \l__cg_cell_height_dim \dim_new:N \l__cg_header_height_dim \dim_new:N \l__cg_row_gap_dim \dim_new:N \l__cg_column_gap_dim \dim_new:N \l__cg_year_cell_width_dim \tl_new:N \l__cg_layout_tl \tl_new:N \l__cg_overflow_tl \tl_new:N \l__cg_date_tl \tl_new:N \l__cg_iso_tl \tl_new:N \l__cg_event_tl \tl_new:N \l__cg_range_styles_tl \tl_new:N \l__cg_range_style_tl \tl_new:N \l__cg_year_shortnames_tl \seq_new:N \l__cg_date_match_seq \seq_new:N \l__cg_month_names_seq \seq_new:N \l__cg_weekday_names_seq \seq_new:N \l__cg_weekday_short_names_seq \seq_new:N \l__cg_active_range_styles_seq \seq_new:N \g__cg_range_ids_seq \prop_new:N \g__cg_events_prop \prop_new:N \g__cg_ranges_start_prop \prop_new:N \g__cg_ranges_end_prop \prop_new:N \g__cg_ranges_style_prop \cs_generate_variant:Nn \prop_get:NnNTF { NVNTF } \cs_generate_variant:Nn \prop_get:NnN { NVN } \cs_generate_variant:Nn \prop_gput:Nnn { NnV, NVn, NVV, Nnx, NVx } \cs_generate_variant:Nn \seq_if_in:NnTF { NVTF } \cs_generate_variant:Nn \seq_put_right:Nn { NV } \cs_generate_variant:Nn \seq_gput_right:Nn { NV } \seq_set_from_clist:Nn \l__cg_month_names_seq { January,February,March,April,May,June,July,August,September,October,November,December } \seq_set_from_clist:Nn \l__cg_weekday_names_seq { Monday,Tuesday,Wednesday,Thursday,Friday,Saturday,Sunday } \seq_set_from_clist:Nn \l__cg_weekday_short_names_seq { Mon,Tue,Wed,Thu,Fri,Sat,Sun } \cs_new_protected:Npn \__cg_leap:nTF #1#2#3 { \int_compare:nNnTF { \int_mod:nn {#1} {400} } = {0} {#2} { \int_compare:nNnTF { \int_mod:nn {#1} {100} } = {0} {#3} { \int_compare:nNnTF { \int_mod:nn {#1} {4} } = {0} {#2} {#3} } } } \cs_new_protected:Npn \__cg_days_in_month:nnN #1#2#3 { \int_case:nnF {#2} { {1}{\int_set:Nn #3 {31}} {2}{\__cg_leap:nTF {#1}{\int_set:Nn #3 {29}}{\int_set:Nn #3 {28}}} {3}{\int_set:Nn #3 {31}} {4}{\int_set:Nn #3 {30}} {5}{\int_set:Nn #3 {31}} {6}{\int_set:Nn #3 {30}} {7}{\int_set:Nn #3 {31}} {8}{\int_set:Nn #3 {31}} {9}{\int_set:Nn #3 {30}} {10}{\int_set:Nn #3 {31}} {11}{\int_set:Nn #3 {30}} {12}{\int_set:Nn #3 {31}} } {\int_zero:N #3} } % Julian day number, with Monday = 1 and Sunday = 7. The 4800-year offset % keeps all divisions positive for the supported civil years 0001--9999. \cs_new_protected:Npn \__cg_serial:nnnN #1#2#3#4 { \int_set:Nn \l__cg_jdn_a_int { \int_div_truncate:nn {14-#2}{12} } \int_set:Nn \l__cg_jdn_y_int { #1 + 4800 - \l__cg_jdn_a_int } \int_set:Nn \l__cg_jdn_m_int { #2 + 12*\l__cg_jdn_a_int - 3 } \int_set:Nn #4 { #3 + \int_div_truncate:nn {153*\l__cg_jdn_m_int+2}{5} + 365*\l__cg_jdn_y_int + \int_div_truncate:nn {\l__cg_jdn_y_int}{4} - \int_div_truncate:nn {\l__cg_jdn_y_int}{100} + \int_div_truncate:nn {\l__cg_jdn_y_int}{400} - 32045 } } \cs_new_protected:Npn \__cg_parse_date:n #1 { \bool_set_false:N \l__cg_date_valid_bool \regex_extract_once:nnNTF {\A([0-9]{4})-([0-9]{2})-([0-9]{2})\Z} {#1} \l__cg_date_match_seq { \int_set:Nn \l__cg_date_year_int {\seq_item:Nn \l__cg_date_match_seq {2}} \int_set:Nn \l__cg_date_month_int {\seq_item:Nn \l__cg_date_match_seq {3}} \int_set:Nn \l__cg_date_day_int {\seq_item:Nn \l__cg_date_match_seq {4}} \int_compare:nNnT {\l__cg_date_year_int} > {0} { \int_compare:nNnT {\l__cg_date_year_int} < {10000} { \int_compare:nNnT {\l__cg_date_month_int} > {0} { \int_compare:nNnT {\l__cg_date_month_int} < {13} { \__cg_days_in_month:nnN {\l__cg_date_year_int} {\l__cg_date_month_int} \l__cg_month_days_int \int_compare:nNnT {\l__cg_date_day_int} > {0} {\int_compare:nNnT {\l__cg_date_day_int} < {\l__cg_month_days_int+1} {\bool_set_true:N \l__cg_date_valid_bool}} } } } } } { } \bool_if:NF \l__cg_date_valid_bool {\msg_error:nnn {calendar-grid}{bad-date}{#1}} } \cs_new_protected:Npn \__cg_date_serial:nN #1#2 { \__cg_parse_date:n {#1} \bool_if:NT \l__cg_date_valid_bool {\__cg_serial:nnnN {\l__cg_date_year_int}{\l__cg_date_month_int}{\l__cg_date_day_int}#2} } \cs_new:Npn \__cg_pad_two:n #1 {\int_compare:nNnT {#1} < {10} {0}\int_to_arabic:n {#1}} \cs_new:Npn \__cg_pad_four:n #1 { \int_compare:nNnT {#1} < {1000} {0} \int_compare:nNnT {#1} < {100} {0} \int_compare:nNnT {#1} < {10} {0} \int_to_arabic:n {#1} } \keys_define:nn { calendar-grid } { year .int_set:N = \l__cg_year_int, year .initial:n = 2027, month .int_set:N = \l__cg_month_int, month .initial:n = 1, columns .int_set:N = \l__cg_columns_int, columns .initial:n = 3, week-start .choices:nn = {monday,tuesday,wednesday,thursday,friday,saturday,sunday} {\int_set:Nn \l__cg_week_start_int {\l_keys_choice_int}}, week-start .initial:n = monday, layout .choices:nn = {fixed,compact} {\tl_set:NV \l__cg_layout_tl \l_keys_choice_tl}, layout .initial:n = fixed, overflow .choices:nn = {adjacent,blank} {\tl_set:NV \l__cg_overflow_tl \l_keys_choice_tl}, overflow .initial:n = adjacent, cell-width .dim_set:N = \l__cg_cell_width_dim, cell-width .initial:n = 1.35cm, cell-height .dim_set:N = \l__cg_cell_height_dim, cell-height .initial:n = 8mm, header-height .dim_set:N = \l__cg_header_height_dim, header-height .initial:n = 7mm, row-gap .dim_set:N = \l__cg_row_gap_dim, row-gap .initial:n = 1pt, column-gap .dim_set:N = \l__cg_column_gap_dim, column-gap .initial:n = 2pt, month-names .code:n = {\seq_set_from_clist:Nn \l__cg_month_names_seq {#1}}, weekday-names .code:n = {\seq_set_from_clist:Nn \l__cg_weekday_names_seq {#1}}, weekday-short-names .code:n = {\seq_set_from_clist:Nn \l__cg_weekday_short_names_seq {#1}}, } \keys_define:nn { calendar-grid/range } { style .tl_set:N = \l__cg_range_style_tl, style .initial:n = range } \cs_new_protected:Npn \__cg_check_options: { \int_compare:nNnF {\l__cg_year_int} > {0} {\msg_error:nnn {calendar-grid}{bad-year}{\int_use:N \l__cg_year_int}} \int_compare:nNnF {\l__cg_year_int} < {10000} {\msg_error:nnn {calendar-grid}{bad-year}{\int_use:N \l__cg_year_int}} \int_compare:nNnF {\l__cg_month_int} > {0} {\msg_error:nnn {calendar-grid}{bad-month}{\int_use:N \l__cg_month_int}} \int_compare:nNnF {\l__cg_month_int} < {13} {\msg_error:nnn {calendar-grid}{bad-month}{\int_use:N \l__cg_month_int}} \int_compare:nNnF {\seq_count:N \l__cg_month_names_seq} = {12} {\msg_error:nnnn {calendar-grid}{bad-list}{month~names}{12}} \int_compare:nNnF {\seq_count:N \l__cg_weekday_names_seq} = {7} {\msg_error:nnnn {calendar-grid}{bad-list}{weekday~names}{7}} \int_compare:nNnF {\seq_count:N \l__cg_weekday_short_names_seq} = {7} {\msg_error:nnnn {calendar-grid}{bad-list}{weekday~short~names}{7}} } \cs_new_protected:Npn \__cg_get_cell:n #1 { \int_set:Nn \l__cg_index_int {#1} \int_set_eq:NN \l__cg_cell_year_int \l__cg_year_int \int_set_eq:NN \l__cg_cell_month_int \l__cg_month_int \bool_set_true:N \l__cg_cell_current_bool \int_compare:nNnTF {#1} < {\l__cg_offset_int} { \bool_set_false:N \l__cg_cell_current_bool \int_compare:nNnTF {\l__cg_month_int} = {1} {\int_decr:N \l__cg_cell_year_int \int_set:Nn \l__cg_cell_month_int {12}} {\int_decr:N \l__cg_cell_month_int} \__cg_days_in_month:nnN {\l__cg_cell_year_int}{\l__cg_cell_month_int}\l_tmpa_int \int_set:Nn \l__cg_cell_day_int {\l_tmpa_int-\l__cg_offset_int+#1+1} } { \int_compare:nNnTF {#1} < {\l__cg_offset_int+\l__cg_month_days_int} {\int_set:Nn \l__cg_cell_day_int {#1-\l__cg_offset_int+1}} { \bool_set_false:N \l__cg_cell_current_bool \int_compare:nNnTF {\l__cg_month_int} = {12} {\int_incr:N \l__cg_cell_year_int \int_set:Nn \l__cg_cell_month_int {1}} {\int_incr:N \l__cg_cell_month_int} \int_set:Nn \l__cg_cell_day_int {#1-\l__cg_offset_int-\l__cg_month_days_int+1} } } \tl_set:Nx \l__cg_iso_tl { \__cg_pad_four:n {\l__cg_cell_year_int}- \__cg_pad_two:n {\l__cg_cell_month_int}- \__cg_pad_two:n {\l__cg_cell_day_int} } \int_set:Nn \l__cg_cell_weekday_int {\int_mod:nn {\l__cg_week_start_int-1 + \int_mod:nn {#1}{7}}{7}+1} \int_set:Nn \l__cg_cell_column_int {\int_mod:nn {#1}{7}+1} \int_set:Nn \l__cg_cell_row_int {\int_div_truncate:nn {#1}{7}+1} \int_compare:nNnTF {\l__cg_cell_weekday_int} > {5} {\bool_set_true:N \l__cg_cell_weekend_bool} {\bool_set_false:N \l__cg_cell_weekend_bool} \__cg_serial:nnnN {\l__cg_cell_year_int}{\l__cg_cell_month_int}{\l__cg_cell_day_int}\l__cg_cell_serial_int } \cs_new_protected:Npn \__cg_get_ranges: { \seq_clear:N \l__cg_active_range_styles_seq \seq_map_inline:Nn \g__cg_range_ids_seq { \prop_get:NnN \g__cg_ranges_start_prop {##1}\l_tmpa_tl \prop_get:NnN \g__cg_ranges_end_prop {##1}\l_tmpb_tl \int_compare:nNnT {\l__cg_cell_serial_int} > {\l_tmpa_tl-1} { \int_compare:nNnT {\l__cg_cell_serial_int} < {\l_tmpb_tl+1} { \prop_get:NnN \g__cg_ranges_style_prop {##1}\l_tmpc_tl \seq_if_in:NVTF \l__cg_active_range_styles_seq \l_tmpc_tl { } {\seq_put_right:NV \l__cg_active_range_styles_seq \l_tmpc_tl} } } } \tl_set:Nx \l__cg_range_styles_tl {\seq_use:Nn \l__cg_active_range_styles_seq {,~}} \seq_if_empty:NTF \l__cg_active_range_styles_seq {\bool_set_false:N \l__cg_cell_range_bool} {\bool_set_true:N \l__cg_cell_range_bool} } % Presentation hooks: redefine these commands to suit a document's visual style. \NewDocumentCommand \CalendarGridFormatMonthTitle { m m } {\par\medskip\centering\bfseries #1\space #2\par\smallskip} \NewDocumentCommand \CalendarGridFormatDay { m m m m m m m m m } { \parbox[c][\l__cg_cell_height_dim][c]{\l__cg_cell_width_dim} { \centering \str_if_eq:nnTF {#3}{true} {\tl_if_blank:nTF {#9}{\str_if_eq:nnTF {#5}{true}{\bfseries #2}{#2}}{\bfseries #2}} {\textit{#2}} \tl_if_blank:nF {#8} {\par\scriptsize #8} } } \cs_new_protected:Npn \__cg_render_cell:n #1 { \__cg_get_cell:n {#1} \__cg_get_ranges: \prop_get:NVNTF \g__cg_events_prop \l__cg_iso_tl \l__cg_event_tl { } {\tl_clear:N \l__cg_event_tl} \bool_if:NTF \l__cg_cell_current_bool { \use:x { \exp_not:N \CalendarGridFormatDay {\exp_not:V \l__cg_iso_tl} {\int_use:N \l__cg_cell_day_int} {true} {\int_use:N \l__cg_cell_weekday_int} {\bool_if:NTF \l__cg_cell_weekend_bool {true}{false}} {\int_use:N \l__cg_cell_row_int} {\int_use:N \l__cg_cell_column_int} {\exp_not:V \l__cg_event_tl} {\exp_not:V \l__cg_range_styles_tl} } } { \tl_if_eq:NnTF \l__cg_overflow_tl {adjacent} { \use:x { \exp_not:N \CalendarGridFormatDay {\exp_not:V \l__cg_iso_tl} {\int_use:N \l__cg_cell_day_int} {false} {\int_use:N \l__cg_cell_weekday_int} {\bool_if:NTF \l__cg_cell_weekend_bool {true}{false}} {\int_use:N \l__cg_cell_row_int} {\int_use:N \l__cg_cell_column_int} {\exp_not:V \l__cg_event_tl} {\exp_not:V \l__cg_range_styles_tl} } } {\parbox[c][\l__cg_cell_height_dim][c]{\l__cg_cell_width_dim}{\strut}} } } \cs_new_protected:Npn \__cg_compute_layout: { \__cg_days_in_month:nnN {\l__cg_year_int}{\l__cg_month_int}\l__cg_month_days_int \__cg_serial:nnnN {\l__cg_year_int}{\l__cg_month_int}{1}\l__cg_serial_int \int_set:Nn \l__cg_first_weekday_int {\int_mod:nn {\l__cg_serial_int}{7}+1} \int_set:Nn \l__cg_offset_int {\int_mod:nn {\l__cg_first_weekday_int-\l__cg_week_start_int+7}{7}} \tl_if_eq:NnTF \l__cg_layout_tl {fixed} {\int_set:Nn \l__cg_rows_int {6}} {\int_set:Nn \l__cg_rows_int {\int_div_truncate:nn {\l__cg_offset_int+\l__cg_month_days_int+6}{7}}} \int_set:Nn \l__cg_cells_int {7*\l__cg_rows_int} } \cs_new_protected:Npn \__cg_render: { \__cg_check_options: \__cg_compute_layout: \CalendarGridFormatMonthTitle {\seq_item:Nn \l__cg_month_names_seq {\l__cg_month_int}} {\int_use:N \l__cg_year_int} \group_begin: \setlength{\tabcolsep}{\l__cg_column_gap_dim} \setlength{\extrarowheight}{\l__cg_row_gap_dim} \begin{tabular}{|*{7}{>{\centering\arraybackslash}p{\l__cg_cell_width_dim}|}} \hline \int_step_inline:nn {7} { \int_set:Nn \l_tmpa_int {\int_mod:nn {\l__cg_week_start_int-1+##1-1}{7}+1} \parbox[c][\l__cg_header_height_dim][c]{\l__cg_cell_width_dim} {\centering\scriptsize\seq_item:Nn \l__cg_weekday_short_names_seq {\l_tmpa_int}} \int_compare:nNnTF {##1} = {7} {\\\hline} {&} } \int_step_inline:nn {\l__cg_cells_int} { \__cg_render_cell:n {##1-1} \int_compare:nNnTF {\int_mod:nn {##1}{7}} = {0} {\\\hline} {&} } \end{tabular} \group_end: } \NewDocumentCommand \CalendarGrid { O{} } {\group_begin:\keys_set:nn {calendar-grid}{#1}\__cg_render:\group_end:} \NewDocumentCommand \CalendarGridWeekday { m } { \group_begin: \__cg_date_serial:nN {#1}\l__cg_serial_int \bool_if:NT \l__cg_date_valid_bool {\int_set:Nn \l_tmpa_int {\int_mod:nn {\l__cg_serial_int}{7}+1}\seq_item:Nn \l__cg_weekday_names_seq {\l_tmpa_int}} \group_end: } \NewDocumentCommand \CalendarGridEvent { m m } { \group_begin: \__cg_parse_date:n {#1} \bool_if:NT \l__cg_date_valid_bool { \prop_get:NnNTF \g__cg_events_prop {#1}\l_tmpa_tl {\tl_put_right:Nn \l_tmpa_tl {\newline #2}\prop_gput:NnV \g__cg_events_prop {#1}\l_tmpa_tl} {\prop_gput:Nnn \g__cg_events_prop {#1}{#2}} } \group_end: } \NewDocumentCommand \CalendarGridRange { O{} m m } { \group_begin: \tl_set:Nn \l__cg_range_style_tl {range} \keys_set:nn {calendar-grid/range}{#1} \__cg_date_serial:nN {#2}\l_tmpa_int \bool_set_eq:NN \l_tmpa_bool \l__cg_date_valid_bool \__cg_date_serial:nN {#3}\l_tmpb_int \bool_if:nT {\l_tmpa_bool && \l__cg_date_valid_bool} { \int_compare:nNnTF {\l_tmpa_int} > {\l_tmpb_int} {\msg_error:nnnn {calendar-grid}{reversed-range}{#2}{#3}} { \int_gincr:N \g__cg_range_id_int \tl_set:Nx \l_tmpc_tl {\int_use:N \g__cg_range_id_int} \seq_gput_right:NV \g__cg_range_ids_seq \l_tmpc_tl \prop_gput:NVx \g__cg_ranges_start_prop \l_tmpc_tl {\int_use:N \l_tmpa_int} \prop_gput:NVx \g__cg_ranges_end_prop \l_tmpc_tl {\int_use:N \l_tmpb_int} \prop_gput:NVV \g__cg_ranges_style_prop \l_tmpc_tl \l__cg_range_style_tl } } \group_end: } \NewDocumentCommand \CalendarYear { O{} } { \group_begin: \keys_set:nn {calendar-grid}{#1} \__cg_check_options: \int_compare:nNnTF {\l__cg_columns_int} < {1} {\msg_error:nn {calendar-grid}{bad-columns}\int_set:Nn\l__cg_columns_int{3}} {\int_compare:nNnT {\l__cg_columns_int} > {4} {\msg_error:nn {calendar-grid}{bad-columns}\int_set:Nn\l__cg_columns_int{3}}} \dim_set:Nn \l__cg_year_cell_width_dim {\dim_eval:n {\linewidth/\l__cg_columns_int-2\tabcolsep}} \begin{tabular}{*{\int_use:N \l__cg_columns_int}{c}} \int_step_inline:nn {12} { \group_begin: \begin{minipage}[t]{\l__cg_year_cell_width_dim} \int_set:Nn \l__cg_month_int {##1} \dim_set:Nn \l__cg_cell_width_dim {\dim_eval:n { (\l__cg_year_cell_width_dim-14pt)/7 }} \dim_set:Nn \l__cg_cell_height_dim {.55cm} \dim_set:Nn \l__cg_header_height_dim {.45cm} \dim_set:Nn \l__cg_column_gap_dim {1pt} \dim_set:Nn \l__cg_row_gap_dim {0pt} \scriptsize\__cg_render: \end{minipage} \group_end: \int_compare:nNnTF {\int_mod:nn {##1}{\l__cg_columns_int}} = {0} {\\[\baselineskip]} {&} } \end{tabular} \group_end: } \ExplSyntaxOff \endinput