aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--bin/dune1
-rw-r--r--bin/main.ml269
-rw-r--r--bin/pretty.ml97
-rw-r--r--man/colitur.134
-rw-r--r--test/cli.t43
5 files changed, 422 insertions, 22 deletions
diff --git a/bin/dune b/bin/dune
index 09c2009..a143250 100644
--- a/bin/dune
+++ b/bin/dune
@@ -1,5 +1,6 @@
(executable
(name main)
+ (modules main pretty)
(public_name colitur)
(package colitur)
; [unix] ships with the OCaml compiler -- it is not a new entry in
diff --git a/bin/main.ml b/bin/main.ml
index 49f06e5..7c6427b 100644
--- a/bin/main.ml
+++ b/bin/main.ml
@@ -29,7 +29,7 @@ let easter_report y =
same claim. [Colitur_kernel.Record.of_temporal] was already fully
polymorphic over [('s, 'r)] (record.mli), so this is the same
plumbing already done for `day`/`readings`/etc, not new library work. *)
-let temporal_report ~rite y =
+let temporal_report ~rite ~pretty y =
let jan1 = match D.make ~year:y ~month:1 ~day:1 with
| Ok t -> t
| Error e -> failwith e
@@ -54,10 +54,28 @@ let temporal_report ~rite y =
let d = ref jan1 in
while D.compare !d dec31 <= 0 do
let r = record_of_day !d in
- Printf.printf "%s %s %s %s %s %s %s\n" r.Colitur_kernel.Record.date
- r.Colitur_kernel.Record.weekday r.Colitur_kernel.Record.season
- (field r.Colitur_kernel.Record.week) r.Colitur_kernel.Record.slug
- r.Colitur_kernel.Record.rank r.Colitur_kernel.Record.colour;
+ (if pretty then
+ (* The temporal cycle has no sanctoral, so there is never a
+ commemoration to indent -- same row shape as `day --pretty`, minus
+ that. Shown here from the flat Record rather than a Liturgical_day
+ because that is all this report ever had. *)
+ Printf.printf "%s %s %s%s %s%s\n"
+ (Pretty.pad Pretty.w_date r.Colitur_kernel.Record.date)
+ (Pretty.pad Pretty.w_dow
+ (let w = r.Colitur_kernel.Record.weekday in
+ String.sub w 0 (min 3 (String.length w))))
+ (Pretty.swatch r.Colitur_kernel.Record.colour)
+ (Printf.sprintf " %s" (Pretty.pad Pretty.w_rank r.Colitur_kernel.Record.rank))
+ (Pretty.pad Pretty.w_season
+ (match r.Colitur_kernel.Record.week with
+ | "" -> r.Colitur_kernel.Record.season
+ | w -> r.Colitur_kernel.Record.season ^ " " ^ w))
+ r.Colitur_kernel.Record.slug
+ else
+ Printf.printf "%s %s %s %s %s %s %s\n" r.Colitur_kernel.Record.date
+ r.Colitur_kernel.Record.weekday r.Colitur_kernel.Record.season
+ (field r.Colitur_kernel.Record.week) r.Colitur_kernel.Record.slug
+ r.Colitur_kernel.Record.rank r.Colitur_kernel.Record.colour);
d := D.add_days !d 1
done
@@ -764,9 +782,114 @@ let resolved_year_days ~overlays y =
let resolved_year_report ~line ~overlays y =
List.iter line (resolved_year_days ~overlays y)
-let day_report ~lang ~overlays y = resolved_year_report ~line:(day_line ~lang) ~overlays y
-let readings_report ~lang ~sigla ~overlays y =
- resolved_year_report ~line:(readings_line ~lang ~sigla) ~overlays y
+
+(* ---------------------------------------------------------------------- *)
+(* --pretty: the same days, laid out for a person rather than for awk.
+ *
+ * These mirror the plain formatters above one for one and share their data;
+ * they do NOT recompute anything. The plain output stays byte-identical --
+ * see bin/pretty.ml's own note on why this format is free to change while
+ * every other one is a contract. *)
+
+let pretty_day_generic ~date ~dow ~colour ~rank ~season ~week ~name ~comms =
+ let season_col =
+ match week with
+ | Some w -> Printf.sprintf "%s %s" season w
+ | None -> season
+ in
+ (* The prefix is built, then MEASURED, rather than its width being assumed
+ from the column constants. Those constants are a minimum, not a maximum
+ -- a season name longer than its column (the Latin "Tempus per annum ante
+ Septuagesimam" is 35 against 34) pushes everything after it right, and an
+ indent computed from the constants would then sit under the wrong column
+ on exactly the rows that have something to indent. *)
+ let prefix =
+ Printf.sprintf "%s %s " (Pretty.pad Pretty.w_date date)
+ (Pretty.pad Pretty.w_dow (String.sub dow 0 (min 3 (String.length dow))))
+ in
+ let mid =
+ Printf.sprintf " %s %s"
+ (Pretty.pad Pretty.w_rank rank)
+ (Pretty.pad Pretty.w_season season_col)
+ in
+ Printf.printf "%s%s%s%s\n" prefix (Pretty.swatch colour) mid name;
+ (* Commemorations take their own indented line rather than a suffix: the EF
+ admits up to three, and appending them runs the row past any sensible
+ terminal width on precisely the days worth reading. The swatch counts as
+ one display column however it was rendered. *)
+ let indent = Pretty.utf8_len prefix + 1 + Pretty.utf8_len mid in
+ List.iter
+ (fun c -> Printf.printf "%s%s\n" (String.make indent ' ') (Pretty.dim ("+ " ^ c)))
+ comms
+
+let day_line_pretty ~lang (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kernel.Liturgical_day.t) =
+ let t = d.Colitur_kernel.Liturgical_day.temporal in
+ let cel = d.Colitur_kernel.Liturgical_day.observed in
+ let slug_s = Colitur_kernel.Slug.to_string cel.Colitur_kernel.Celebration.slug in
+ let name = Colitur_naming.Lang.celebration lang slug_s in
+ pretty_day_generic
+ ~date:(D.to_iso8601 d.Colitur_kernel.Liturgical_day.date)
+ ~dow:(D.weekday_to_string t.Colitur_kernel.Temporal.weekday)
+ ~colour:(Colitur_kernel.Colour.to_string cel.Colitur_kernel.Celebration.colour)
+ ~rank:(Colitur_naming.Lang.rank lang (Rite_ef.Vocab_ef.rank_to_string cel.Colitur_kernel.Celebration.rank))
+ ~season:(Colitur_naming.Lang.season lang (Rite_ef.Vocab_ef.season_to_string t.Colitur_kernel.Temporal.season))
+ ~week:(match t.Colitur_kernel.Temporal.week with Some n -> Some (string_of_int n) | None -> None)
+ ~name:(if name = slug_s then slug_s else name)
+ ~comms:(List.map
+ (fun (c, _) ->
+ let cs = Colitur_kernel.Slug.to_string c.Colitur_kernel.Celebration.slug in
+ let cn = Colitur_naming.Lang.celebration lang cs in
+ if cn = cs then cs else cn)
+ d.Colitur_kernel.Liturgical_day.commemorations)
+
+let day_report ~lang ~pretty ~overlays y =
+ resolved_year_report
+ ~line:(if pretty then day_line_pretty ~lang else day_line ~lang)
+ ~overlays y
+let readings_pretty_row ~date ~dow ~name ~first ~second ~gospel =
+ Printf.printf "%s %s %s\n"
+ (Pretty.pad Pretty.w_date date)
+ (Pretty.pad Pretty.w_dow (String.sub dow 0 (min 3 (String.length dow))))
+ name;
+ let show label v =
+ if v <> "" && v <> "-" then
+ Printf.printf "%s%s %s\n"
+ (String.make (Pretty.w_date + Pretty.w_dow + 5) ' ')
+ (Pretty.dim (Pretty.pad 8 label)) v
+ in
+ (* Labelled and stacked rather than pipe-separated. A citation is what a
+ person came here to read, and "Isai 63:16b-17, 19b; 64:2-7" is hard to
+ find in a row of three when the separators are bars. The OF's second
+ reading simply does not print on the days that have none, which is most
+ of them. *)
+ show "First" first;
+ show "Second" second;
+ show "Gospel" gospel
+
+let readings_line_pretty ~lang ~sigla (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kernel.Liturgical_day.t) =
+ let cel = d.Colitur_kernel.Liturgical_day.observed in
+ let part_ref p =
+ match
+ List.find_opt (fun (c : Colitur_kernel.Citation.t) ->
+ c.Colitur_kernel.Citation.part = p) d.Colitur_kernel.Liturgical_day.citations
+ with
+ | Some c -> Colitur_citation.Sigla.format sigla c.Colitur_kernel.Citation.reference
+ | None -> ""
+ in
+ let slug_s = Colitur_kernel.Slug.to_string cel.Colitur_kernel.Celebration.slug in
+ let n = Colitur_naming.Lang.celebration lang slug_s in
+ readings_pretty_row
+ ~date:(D.to_iso8601 d.Colitur_kernel.Liturgical_day.date)
+ ~dow:(D.weekday_to_string d.Colitur_kernel.Liturgical_day.temporal.Colitur_kernel.Temporal.weekday)
+ ~name:(if n = slug_s then slug_s else n)
+ ~first:(part_ref Colitur_kernel.Citation.First)
+ ~second:(part_ref Colitur_kernel.Citation.Second)
+ ~gospel:(part_ref Colitur_kernel.Citation.Gospel)
+
+let readings_report ~lang ~sigla ~pretty ~overlays y =
+ resolved_year_report
+ ~line:(if pretty then readings_line_pretty ~lang ~sigla else readings_line ~lang ~sigla)
+ ~overlays y
(* Fix wave I1 (final-review.md, 2026-08-25-colitur-of-phases-3-5): unlike
EF's [Celebration.names] (almost always empty -- lang/la.ini is EF's own
@@ -866,19 +989,96 @@ let readings_line_of ~lang ~sigla
let resolved_of_year_report ~line ~overlays y = List.iter line (resolved_of_year_days ~overlays y)
-let day_report_of ~lang ~overlays y = resolved_of_year_report ~line:(day_line_of ~lang) ~overlays y
-let readings_report_of ~lang ~sigla ~overlays y =
- resolved_of_year_report ~line:(readings_line_of ~lang ~sigla) ~overlays y
+let day_line_of_pretty ~lang (d : (Rite_of.Vocab_of.season, Rite_of.Vocab_of.rank) Colitur_kernel.Liturgical_day.t) =
+ let t = d.Colitur_kernel.Liturgical_day.temporal in
+ let cel = d.Colitur_kernel.Liturgical_day.observed in
+ let slug_s = Colitur_kernel.Slug.to_string cel.Colitur_kernel.Celebration.slug in
+ (* Same preference [day_line_of] itself uses: the shipped OF data's own
+ verified per-slug name first, the generic lang table only as a fallback.
+ Reusing [observed_name_of] rather than restating it keeps the two row
+ shapes from drifting apart. *)
+ let name = observed_name_of ~lang cel slug_s in
+ pretty_day_generic
+ ~date:(D.to_iso8601 d.Colitur_kernel.Liturgical_day.date)
+ ~dow:(D.weekday_to_string t.Colitur_kernel.Temporal.weekday)
+ ~colour:(Colitur_kernel.Colour.to_string cel.Colitur_kernel.Celebration.colour)
+ ~rank:(Colitur_naming.Lang.rank lang (Rite_of.Vocab_of.rank_to_string cel.Colitur_kernel.Celebration.rank))
+ ~season:(Colitur_naming.Lang.season lang (Rite_of.Vocab_of.season_to_string t.Colitur_kernel.Temporal.season))
+ ~week:(match t.Colitur_kernel.Temporal.week with Some n -> Some (string_of_int n) | None -> None)
+ ~name ~comms:[]
+
+let day_report_of ~lang ~pretty ~overlays y =
+ resolved_of_year_report
+ ~line:(if pretty then day_line_of_pretty ~lang else day_line_of ~lang)
+ ~overlays y
+let readings_line_of_pretty ~lang ~sigla (d : (Rite_of.Vocab_of.season, Rite_of.Vocab_of.rank) Colitur_kernel.Liturgical_day.t) =
+ let cel = d.Colitur_kernel.Liturgical_day.observed in
+ let part_ref p =
+ match
+ List.find_opt (fun (c : Colitur_kernel.Citation.t) ->
+ c.Colitur_kernel.Citation.part = p) d.Colitur_kernel.Liturgical_day.citations
+ with
+ | Some c -> Colitur_citation.Sigla.format sigla c.Colitur_kernel.Citation.reference
+ | None -> ""
+ in
+ let slug_s = Colitur_kernel.Slug.to_string cel.Colitur_kernel.Celebration.slug in
+ readings_pretty_row
+ ~date:(D.to_iso8601 d.Colitur_kernel.Liturgical_day.date)
+ ~dow:(D.weekday_to_string d.Colitur_kernel.Liturgical_day.temporal.Colitur_kernel.Temporal.weekday)
+ ~name:(observed_name_of ~lang cel slug_s)
+ ~first:(part_ref Colitur_kernel.Citation.First)
+ ~second:(part_ref Colitur_kernel.Citation.Second)
+ ~gospel:(part_ref Colitur_kernel.Citation.Gospel)
+
+let readings_report_of ~lang ~sigla ~pretty ~overlays y =
+ resolved_of_year_report
+ ~line:(if pretty then readings_line_of_pretty ~lang ~sigla else readings_line_of ~lang ~sigla)
+ ~overlays y
(* Fix 1 (cli-flags-report, 2026-08-27): `rubrics` widened to `--rite of`.
[rubrics_line] above needs no OF twin (it type-checks polymorphic over
[('s, 'r)] already -- see its own header), so dispatching here is only
ever about which resolver walks the year, never about which printer
prints it. *)
-let rubrics_report ~rite ~lang ~overlays y =
+
+(* --pretty for `readings` and `rubrics`. Both keep the same one-row-per-day
+ shape as `day --pretty`, so the four commands scan alike; only the columns
+ after the date differ, because what they are FOR differs. *)
+
+let rubrics_pretty_row ~date ~said ~via ~creed ~gloria ~preface =
+ let tick b = if b then "yes" else Pretty.dim "no" in
+ Printf.printf "%s %s %s %s %s %s\n"
+ (Pretty.pad Pretty.w_date date)
+ (Pretty.pad 34 said)
+ (Pretty.pad 10 via)
+ (Pretty.pad 8 ("Creed " ^ tick creed))
+ (Pretty.pad 9 ("Gloria " ^ tick gloria))
+ preface
+
+
+let rubrics_line_pretty (d : (_, _) Colitur_kernel.Liturgical_day.t) =
+ let said, via =
+ match d.Colitur_kernel.Liturgical_day.formulary with
+ | Some f ->
+ ((match f.Colitur_kernel.Mass_formulary.said with
+ | Some sl -> Colitur_kernel.Slug.to_string sl
+ | None -> "-"),
+ Colitur_kernel.Mass_formulary.source_to_string f.Colitur_kernel.Mass_formulary.via)
+ | None -> ("-", "-")
+ in
+ rubrics_pretty_row
+ ~date:(D.to_iso8601 d.Colitur_kernel.Liturgical_day.date)
+ ~said ~via
+ ~creed:d.Colitur_kernel.Liturgical_day.creed
+ ~gloria:d.Colitur_kernel.Liturgical_day.gloria
+ ~preface:(match d.Colitur_kernel.Liturgical_day.preface with
+ | Some pf -> Colitur_kernel.Preface.to_string pf
+ | None -> "-")
+
+let rubrics_report ~rite ~lang ~pretty ~overlays y =
match rite with
- | `Ef -> resolved_year_report ~line:(rubrics_line ~lang) ~overlays y
- | `Of -> resolved_of_year_report ~line:(rubrics_line ~lang) ~overlays y
+ | `Ef -> resolved_year_report ~line:(if pretty then rubrics_line_pretty else rubrics_line ~lang) ~overlays y
+ | `Of -> resolved_of_year_report ~line:(if pretty then rubrics_line_pretty else rubrics_line ~lang) ~overlays y
(* Fix 1 (cli-flags-report, 2026-08-27): shared by [table_report] and
[publish_report], which each need only the rendered [Template.value] --
@@ -2185,6 +2385,7 @@ type parsed_args = {
prune : bool;
lang : string option;
raw : bool;
+ pretty : bool;
dump : string option;
check : string option;
list : bool;
@@ -2222,6 +2423,7 @@ let parse_args argv =
| "--lang" :: v :: rest -> go { acc with lang = Some v } rest
| [ "--lang" ] -> Error "--lang needs a language code or file path"
| "--raw" :: rest -> go { acc with raw = true } rest
+ | "--pretty" :: rest -> go { acc with pretty = true } rest
| "--dump" :: v :: rest -> go { acc with dump = Some v } rest
| [ "--dump" ] -> Error "--dump needs a language code"
| "--check" :: v :: rest -> go { acc with check = Some v } rest
@@ -2246,7 +2448,7 @@ let parse_args argv =
in
go
{ overlays = []; rite = None; format = None; from_y = None; to_y = None; dtstamp = None; year = None;
- template = None; flavour = None; out = None; prune = false; lang = None; raw = false;
+ template = None; flavour = None; out = None; prune = false; lang = None; raw = false; pretty = false;
dump = None; check = None; list = false; show = false; sigla_style = None; sigla_book = None;
sigla_tradition = None; positional = [] }
argv
@@ -2382,6 +2584,20 @@ let reject_publish_flags_for cmd ~out ~prune =
`check`/`convert`/`new-overlay` (operate on overlay files, not a
rendered calendar), or `lang`/`config` themselves (which take their OWN
flags, `--dump`/`--check`/`--list`/`--show`, disjoint from these). *)
+(* Sibling to [reject_lang_for] and the rest: --pretty is terminal
+ presentation for the four commands that print one row per day. `emit`,
+ `table`/`render` and `publish` already choose their own shape through
+ --format and --template, so prettifying them would compete with the
+ template engine rather than complement it; `easter` prints six key/value
+ lines, not a day grid. Accepting the flag there and quietly doing nothing
+ is the failure mode this program refuses everywhere else. *)
+let reject_pretty_for cmd ~pretty =
+ if pretty then begin
+ Printf.eprintf
+ "colitur: --pretty has no effect on `%s`; refusing rather than ignoring it\n" cmd;
+ exit 2
+ end
+
let reject_lang_for cmd ~lang ~raw =
if lang <> None || raw then begin
Printf.eprintf "colitur: --lang/--raw have no effect on `%s`; refusing rather than ignoring them\n" cmd;
@@ -2865,13 +3081,15 @@ let () =
Printf.eprintf "colitur: %s\n" msg;
usage ()
| Ok { overlays; rite; format; from_y; to_y; dtstamp; year; template; flavour; out; prune; lang;
- raw; dump; check; list; show; sigla_style; sigla_book; sigla_tradition; positional } -> (
+ raw; pretty; dump; check; list; show; sigla_style; sigla_book; sigla_tradition;
+ positional } -> (
let reject_emit = reject_emit_flags_for ~format ~from_y ~to_y ~dtstamp in
let reject_rite cmd = reject_rite_for cmd rite in
let reject_table = reject_table_flags_for ~year ~template ~flavour in
let reject_template_flavour cmd = reject_template_flavour_for cmd ~template ~flavour in
let reject_publish = reject_publish_flags_for ~out ~prune in
let reject_lang = reject_lang_for ~lang ~raw in
+ let reject_pretty = reject_pretty_for ~pretty in
let reject_lang_sub = reject_lang_subcommand_flags_for ~dump ~check ~list ~show in
let reject_sigla = reject_sigla_for ~sigla_style ~sigla_book ~sigla_tradition in
(* Loaded once, unconditionally: a config file the user wrote and
@@ -2948,6 +3166,7 @@ let () =
reject_publish "easter";
reject_lang "easter";
reject_lang_sub "easter";
+ reject_pretty "easter";
reject_sigla "easter";
with_year ys easter_report
| [ "temporal"; ys ] ->
@@ -2958,7 +3177,7 @@ let () =
reject_lang "temporal";
reject_lang_sub "temporal";
reject_sigla "temporal";
- with_year ys (temporal_report ~rite:(resolve_rite rite))
+ with_year ys (temporal_report ~rite:(resolve_rite rite) ~pretty)
| "check" :: (_ :: _ as files) ->
reject_overlays_for "check" overlays;
reject_rite "check";
@@ -2967,6 +3186,7 @@ let () =
reject_publish "check";
reject_lang "check";
reject_lang_sub "check";
+ reject_pretty "check";
reject_sigla "check";
check_report files
| [ "convert"; path ] ->
@@ -2977,6 +3197,7 @@ let () =
reject_publish "convert";
reject_lang "convert";
reject_lang_sub "convert";
+ reject_pretty "convert";
reject_sigla "convert";
convert_report path
| [ "new-overlay" ] ->
@@ -2987,6 +3208,7 @@ let () =
reject_publish "new-overlay";
reject_lang "new-overlay";
reject_lang_sub "new-overlay";
+ reject_pretty "new-overlay";
reject_sigla "new-overlay";
print_string new_overlay_template;
exit 0
@@ -3066,8 +3288,8 @@ let () =
~flag:year
in
(match resolve_rite rite with
- | `Ef -> with_year ys (day_report ~lang:(resolved_lang ()) ~overlays:effective_overlays)
- | `Of -> with_year ys (day_report_of ~lang:(resolved_lang ()) ~overlays:effective_overlays))
+ | `Ef -> with_year ys (day_report ~lang:(resolved_lang ()) ~pretty ~overlays:effective_overlays)
+ | `Of -> with_year ys (day_report_of ~lang:(resolved_lang ()) ~pretty ~overlays:effective_overlays))
| "readings" :: rest when List.length rest <= 1 ->
reject_emit "readings";
reject_template_flavour "readings";
@@ -3084,8 +3306,8 @@ let () =
~sigla_tradition_flag:sigla_tradition ~config
in
(match resolve_rite rite with
- | `Ef -> with_year ys (readings_report ~lang:lang_t ~sigla ~overlays:effective_overlays)
- | `Of -> with_year ys (readings_report_of ~lang:lang_t ~sigla ~overlays:effective_overlays))
+ | `Ef -> with_year ys (readings_report ~lang:lang_t ~sigla ~pretty ~overlays:effective_overlays)
+ | `Of -> with_year ys (readings_report_of ~lang:lang_t ~sigla ~pretty ~overlays:effective_overlays))
| "rubrics" :: rest when List.length rest <= 1 ->
(* --overlay accepted, same reasoning as `readings`: an overlay can
change which celebration is observed, hence which Mass formulary
@@ -3120,11 +3342,12 @@ let () =
~positional:(match rest with [ ys ] -> Some ys | _ -> None)
~flag:year
in
- with_year ys (rubrics_report ~rite:(resolve_rite rite) ~lang:(rubrics_lang ()) ~overlays:effective_overlays)
+ with_year ys (rubrics_report ~rite:(resolve_rite rite) ~lang:(rubrics_lang ()) ~pretty ~overlays:effective_overlays)
| [ "emit" ] -> (
reject_table "emit";
reject_publish "emit";
reject_lang_sub "emit";
+ reject_pretty "emit";
match (match format with Some f -> Some f | None -> Colitur_naming.Config.format config) with
| None ->
Printf.eprintf "colitur: emit requires --format csv|json|sexp|xml|ics\n";
@@ -3152,6 +3375,7 @@ let () =
reject_emit cmd;
reject_publish cmd;
reject_lang_sub cmd;
+ reject_pretty cmd;
let positional_year = match rest with [ ys ] -> Some ys | _ -> None in
match (match template with Some t -> Some t | None -> Colitur_naming.Config.template config) with
| None ->
@@ -3180,6 +3404,7 @@ let () =
reject_table "publish";
reject_format_for "publish" format;
reject_lang_sub "publish";
+ reject_pretty "publish";
match out with
| None ->
Printf.eprintf "colitur: publish requires --out DIR\n";
diff --git a/bin/pretty.ml b/bin/pretty.ml
new file mode 100644
index 0000000..53f8e8a
--- /dev/null
+++ b/bin/pretty.ml
@@ -0,0 +1,97 @@
+(* pretty -- terminal presentation for the row commands.
+ *
+ * This module is PRESENTATION ONLY. It never decides what a day is, only how
+ * it is shown, and nothing else in colitur reads it. That separation matters
+ * here more than usual: every other output format this program has is a
+ * contract something parses, and a change to one is a breaking change. This
+ * one is for a person reading a terminal, so it is free to change.
+ *
+ * The default output is deliberately unaffected. `--pretty` is opt-in, and a
+ * command that does not take it refuses rather than ignoring it, like every
+ * other flag here. *)
+
+(* ------------------------------------------------------------ colour *)
+
+(* Colour is emitted only when stdout is a terminal AND NO_COLOR is unset.
+ *
+ * The TTY test is what keeps `colitur day --pretty 2026 | less` and
+ * `> file` free of escape sequences: alignment survives the pipe, colour does
+ * not, which is the behaviour a person actually wants from both.
+ *
+ * NO_COLOR (https://no-color.org) is honoured on PRESENCE, whatever its
+ * value -- that is the convention's own rule, and reading it as a boolean
+ * ("NO_COLOR=0 means colour") is the usual way tools get it wrong. *)
+let use_colour =
+ lazy
+ (match Sys.getenv_opt "NO_COLOR" with
+ | Some _ -> false
+ | None -> ( try Unix.isatty Unix.stdout with Unix.Unix_error _ -> false))
+
+(* The six liturgical colours the engine can emit, as the nearest sensible
+ ANSI. Rose is the one that needs a note: it is a distinct liturgical colour
+ (Gaudete, Laetare), not a shade of red, so it gets bright magenta rather
+ than being folded into red -- collapsing them would lose a distinction the
+ calendar deliberately makes. Black is rendered bright-black (grey) because
+ true black is invisible on a dark terminal, which is where this is mostly
+ read. *)
+let ansi_of_colour = function
+ | "white" -> "\027[97m"
+ | "red" -> "\027[31m"
+ | "green" -> "\027[32m"
+ | "violet" -> "\027[35m"
+ | "rose" -> "\027[95m"
+ | "black" -> "\027[90m"
+ | _ -> "\027[37m"
+
+let reset = "\027[0m"
+
+(* A filled circle in the day's colour. Chosen over tinting the whole row:
+ violet and black text are hard to read on a dark background, and a fully
+ coloured line reads as a status indicator (red = error) rather than as
+ liturgical information. *)
+let swatch colour =
+ if Lazy.force use_colour then ansi_of_colour colour ^ "\xe2\x97\x8f" ^ reset
+ else
+ (* Without colour the swatch would be six identical dots, carrying nothing.
+ Print the colour's own initial instead, so the information survives a
+ pipe rather than silently vanishing with the escapes. *)
+ (match colour with
+ | "white" -> "w" | "red" -> "r" | "green" -> "g"
+ | "violet" -> "v" | "rose" -> "o" | "black" -> "k" | _ -> "?")
+
+let dim s = if Lazy.force use_colour then "\027[2m" ^ s ^ reset else s
+
+(* ------------------------------------------------------------ columns *)
+
+(* Pad to a display width. Counts UTF-8 CODE POINTS rather than bytes: the
+ Latin names carry ae/oe ligatures and accents ("Sanctae Familiae", "Fremiot"
+ in some langs), and padding those by byte length under-pads the column by
+ one per multi-byte character, which shears the whole table. Not a full
+ grapheme or East-Asian-width implementation -- colitur's own languages are
+ Latin-script, and pretending otherwise would be more code claiming more
+ correctness than it has. *)
+let utf8_len s =
+ let n = ref 0 in
+ String.iter (fun c -> if Char.code c land 0xC0 <> 0x80 then incr n) s;
+ !n
+
+(* Pads to [w], and ALWAYS leaves at least one trailing space. The second
+ half matters: the Latin season names are long ("Tempus per annum ante
+ Septuagesimam" is 35 characters against a 22-wide column), and a pad that
+ returns an over-long value unchanged lets the next field butt straight
+ against it -- which is how "...Septuagesimam 1S. Hilarii" happened, the
+ week number and the name fused into one token. An over-wide row is untidy;
+ an ambiguous one is wrong. *)
+let pad w s =
+ let l = utf8_len s in
+ if l >= w then s ^ " " else s ^ String.make (w - l) ' '
+
+(* Column widths, fixed rather than measured over the year. Measuring would
+ align more tightly but needs the whole year buffered before the first line
+ prints, which loses streaming -- and `colitur day --pretty 9999 | head` is
+ a reasonable thing to do. These are sized from the longest real values in
+ the shipped data. *)
+let w_date = 10
+let w_dow = 4
+let w_rank = 18
+let w_season = 34
diff --git a/man/colitur.1 b/man/colitur.1
index 1a330d8..55383ec 100644
--- a/man/colitur.1
+++ b/man/colitur.1
@@ -476,6 +476,40 @@ Print a usage summary to standard output and exit 0.
.TP
.BR \-V ", " \-\-version
Print the version and exit 0.
+.SH PRETTY OUTPUT
+.B \-\-pretty
+lays the rows out for a terminal rather than for
+.BR awk (1):
+aligned columns, the day's liturgical colour as a filled circle, and
+commemorations on their own indented line instead of lengthening the row.
+.PP
+Accepted by
+.BR day ", " readings ", " rubrics " and " temporal .
+Every other command refuses it rather than accepting it and doing nothing:
+.BR emit ", " table ", " render " and " publish
+already choose their shape through
+.B \-\-format
+or
+.BR \-\-template ,
+and
+.B easter
+prints six key/value lines rather than a day grid.
+.PP
+Colour is written only when standard output is a terminal, so redirecting or
+piping yields plain aligned text \-\- the alignment survives, the escape
+sequences do not, and the colour column degrades to its initial
+.RB ( w ", " r ", " g ", " v ", " o ", " k )
+so the information is not simply lost. The
+.B NO_COLOR
+environment variable is honoured on presence, whatever its value, per the
+convention at https://no-color.org.
+.PP
+The default output is unchanged by this flag and remains the parseable one.
+Nothing should be written to parse
+.BR \-\-pretty :
+its layout is free to change, which is precisely what the default format is
+not.
+.PP
.SH OUTPUT FORMAT
.SS day
.RS
diff --git a/test/cli.t b/test/cli.t
index 22890c1..b19090a 100644
--- a/test/cli.t
+++ b/test/cli.t
@@ -1962,3 +1962,46 @@ the file is loaded once, unconditionally, before dispatch:
$ XDG_CONFIG_HOME=xdg-bad colitur day 2027 --raw
colitur: xdg-bad/colitur/config.ini: line 1: "[defaults" looks like a section header but does not end with ']'
[2]
+
+`--pretty` lays the four row commands out for a person rather than for awk.
+Opt-in: the default output above is untouched, byte for byte. Colour appears
+only on a terminal, so this test -- which is a pipe -- sees the letter
+fallback (w/r/g/v/o/k) rather than escape sequences:
+
+ $ colitur day --pretty 2026 | head -1
+ 2026-01-01 thu w I classis Tempus Nativitatis In Octava Nativitatis Domini
+
+A commemoration takes its own indented line rather than lengthening the row:
+
+ $ colitur day --pretty 2026 | sed -n '/2026-01-14/,+1p'
+ 2026-01-14 wed w III classis Tempus per annum ante Septuagesimam 1 S. Hilarii Ep., Conf. et Eccl. Doct.
+ + S. Felicis Presbyt. et Mart.
+
+`readings --pretty` labels and stacks the citations, and prints the OF second
+reading only on the days that have one:
+
+ $ colitur readings --rite of --pretty 2026 | head -4
+ 2026-01-01 thu of-mary-mother-of-god
+ First Num 6:22-27
+ Second Gal 4:4-7
+ Gospel Luc 2:16-21
+
+NO_COLOR is honoured on presence, whatever its value -- that is the
+convention's own rule, and reading it as a boolean is how tools get it wrong:
+
+ $ NO_COLOR=0 colitur day --pretty 2026 | head -1 | cat -v | grep -c 'ESC'
+ 0
+ [1]
+
+The commands that already choose their own shape refuse it, rather than
+accepting it and quietly doing nothing:
+
+ $ colitur emit --from 2026 --to 2026 --format csv --pretty
+ colitur: --pretty has no effect on `emit`; refusing rather than ignoring it
+ [2]
+ $ colitur table --year 2026 --template templates/ef/ordo.txt --pretty
+ colitur: --pretty has no effect on `table`; refusing rather than ignoring it
+ [2]
+ $ colitur easter 2026 --pretty
+ colitur: --pretty has no effect on `easter`; refusing rather than ignoring it
+ [2]