[ANN] PrettyTables.jl v3.5: One Table Specification, Six Back Ends

PrettyTables.jl v3.5 adds backend-agnostic table formats and styles. Until now, making
a table beautiful meant configuring backend-specific objects (TextTableFormat, LatexTableStyle, and so on), so switching back ends required rewriting the whole configuration. The new TableFormat, TableStyle, and LineStyle objects describe the table lines and decorations once, and every back end converts them to its native representation.

Combined with the other backend-agnostic features of v3 (Face-based highlighters, StyledStrings in any cell, merged column labels, row groups, summary rows, and footnotes) the entire presentation below is defined in a single named tuple. Switching the back end is one keyword.

This version is not released yet. I am posting here to check if there is some feedback about the implementation.

The Specification

using PrettyTables

# Meridian constellation status: altitude [km], inclination [°], mass [kg], launch year,
# health state, and fraction of the design lifetime consumed.
data = Any[
     705.3  98.2  1850  2019  :operational  0.93
     705.8  98.2  1850  2021  :operational  0.68
     619.6  97.8  2140  2024  :operational  0.27
    1402.1  25.0   480  2018  :degraded     1.42
    1398.7  25.0   480  2020  :operational  0.81
     402.4  51.6    95  2023  :safe_mode    0.88
     549.9  53.0   112  2025  :operational  0.31
     561.2  53.1   118  2025  :operational  0.29
]

satellites = [
    "Meridian-1", "Meridian-2", "Meridian-3",
    "Relay-A", "Relay-B",
    "Pathfinder-1", "Pathfinder-2", "Pathfinder-3",
]

# A single slate gray draws every structural line: the junctions between horizontal and
# vertical lines then always match, so no line shows segments with mixed colors. The lines
# are differentiated by width and style instead.
STRUCTURE = "#64748b"  # Every table line (slate gray).
CYAN      = "#0284c7"  # Title and column label groups.
AMBER     = "#d97706"  # Row group labels and warnings.
MAGENTA   = "#db2777"  # Highest mass highlight.
GREEN     = "#059669"  # Nominal indicators.
CORAL     = "#dc2626"  # Off-nominal indicators.
GRAY_TEXT = "#64748b"  # Units, footnotes, and source notes.

HEALTH_LABELS = Dict(
    :operational => "● Operational",
    :degraded    => "◐ Degraded",
    :safe_mode   => "○ Safe Mode",
)

# Monospaced font used in the meter column so that the dots and the padded percentages
# align in the back ends with proportional fonts. `Face.font` is converted to the HTML
# `font-family`, the Typst `text-font`, and the Excel font name; the text back end is
# already monospaced and LaTeX ignores the font.
MONO = "DejaVu Sans Mono"

# Render the design lifetime fraction as a five-dot meter followed by the percentage. The
# circle glyphs render as clean typographic symbols in every back end. The percentage is
# padded with no-break spaces (U+00A0) because Typst and HTML collapse consecutive regular
# spaces, which would break the alignment of the column.
function life_meter(f::Number)
    n = clamp(round(Int, min(f, 1) * 5), 0, 5)
    return repeat('●', n) * repeat('○', 5 - n) * "\ua0" * lpad(round(Int, 100f), 3, '\ua0') * "\ua0%"
end

spec = (
    # -- Header and Footer -----------------------------------------------------------------
    title    = "Meridian Constellation — Fleet Status",
    subtitle = "Flight dynamics daily report · 2026-08-31",

    footnotes = [
        (:column_label, 2, 6) => "Fraction of the design lifetime consumed.",
        (:row_label, 4, 0)    => "Relay-A operates 42 % beyond its design life.",
        (:data, 6, 5)         => "Safe mode entered after a star tracker anomaly; recovery in progress.",
    ],
    source_notes = "Source: mean orbital elements from GNSS telemetry, epoch 2026-08-31 12:00 UTC.",

    # -- Layout ----------------------------------------------------------------------------
    show_row_number_column  = true,
    row_number_column_label = "#",
    stubhead_label          = "Satellite",
    row_labels              = satellites,

    row_group_labels = [
        1 => "Sun-Synchronous Imaging",
        4 => "Equatorial Data Relay",
        6 => "Technology Demonstration",
    ],

    column_labels = [
        [MultiColumn(2, "Orbit"), MultiColumn(2, "Spacecraft"), MultiColumn(2, "Mission Status")],
        [
            styled"Altitude {(foreground=#64748b):[km]}",
            styled"Inclination {(foreground=#64748b):[°]}",
            styled"Mass {(foreground=#64748b):[kg]}",
            "Launched",
            "Health",
            "Life Used",
        ],
    ],

    alignment = [:r, :r, :r, :c, :l, :r],

    summary_rows = [
        (data, j) ->
            j == 3 ? "$(sum(data[:, j])) kg" : "",
        (data, j) ->
            j == 1 ? "$(round(sum(data[:, j]) / 8; digits = 1)) km" :
            j == 6 ? "$(round(Int, 100 * sum(data[:, j]) / 8)) %"   : "",
    ],
    summary_row_labels = ["Σ Total", "x̄ Mean"],

    # -- Cell Rendering --------------------------------------------------------------------
    formatters = [
        fmt__printf("%.1f", [1, 2]),
        (v, i, j) -> j == 5 ? HEALTH_LABELS[v] : v,
        (v, i, j) -> j == 6 ? life_meter(v) : v,
    ],

    # Highlighters are backend agnostic: a predicate on the data plus a `Face`. They are
    # applied in order and the first match wins.
    highlighters = [
        Highlighter((d, i, j) -> (j == 5) && (d[i, 5] == :operational),     Face(; foreground = GREEN)),
        Highlighter((d, i, j) -> (j == 5) && (d[i, 5] == :degraded),        Face(; foreground = AMBER,   weight = :bold)),
        Highlighter((d, i, j) -> (j == 5) && (d[i, 5] == :safe_mode),       Face(; foreground = CORAL,   weight = :bold)),
        # No bold here: a synthesized or substituted bold face can change the glyph widths
        # and break the alignment of the meter column. The coral color is the emphasis.
        Highlighter((d, i, j) -> (j == 6) && (d[i, 6] >= 1),                Face(; foreground = CORAL,   font = MONO)),
        Highlighter((d, i, j) -> (j == 6) && (d[i, 6] >= 0.75),             Face(; foreground = AMBER,   font = MONO)),
        Highlighter((d, i, j) -> (j == 6),                                  Face(; foreground = GREEN,   font = MONO)),
        Highlighter((d, i, j) -> (j == 3) && (d[i, 3] == maximum(d[:, 3])), Face(; foreground = MAGENTA, weight = :bold)),
    ],

    # -- Table Format: Which Lines Are Drawn, and Their Design (NEW in v3.5) ---------------
    table_format = TableFormat(;
        top_line                = LineStyle(; width = :thick,  color = STRUCTURE),
        header_line             = LineStyle(; style = :double, color = STRUCTURE),
        merged_header_cell_line = LineStyle(; color = STRUCTURE),
        middle_line             = LineStyle(; color = STRUCTURE),
        bottom_line             = LineStyle(; width = :thick,  color = STRUCTURE),
        center_line             = LineStyle(; color = STRUCTURE),

        horizontal_line_at_merged_column_labels = true,
        horizontal_lines_at_data_rows           = :none,
        horizontal_line_before_row_group_label  = true,
        horizontal_line_after_row_group_label   = true,
        horizontal_line_before_summary_rows     = true,

        vertical_line_at_beginning            = false,
        vertical_line_after_row_number_column = false,
        vertical_line_after_row_label_column  = true,
        vertical_lines_at_data_columns        = :none,
        vertical_line_after_data_columns      = false,
    ),

    # -- Table Style: How Each Section Is Decorated (NEW in v3.5) --------------------------
    style = TableStyle(;
        title                          = Face(; weight = :bold, foreground = CYAN),
        subtitle                       = Face(; slant = :italic, foreground = GRAY_TEXT),
        row_number_label               = Face(; weight = :bold, foreground = GRAY_TEXT),
        row_number                     = Face(; foreground = GRAY_TEXT),
        stubhead_label                 = Face(; weight = :bold, slant = :italic),
        row_label                      = Face(; weight = :bold),
        row_group_label                = Face(; weight = :bold, foreground = AMBER),
        first_line_merged_column_label = Face(; weight = :bold, foreground = CYAN),
        first_line_column_label        = Face(; weight = :bold, foreground = CYAN),
        column_label                   = Face(; weight = :bold),
        summary_row_label              = Face(; weight = :bold, slant = :italic),
        summary_row_cell               = Face(; slant = :italic),
        footnote                       = Face(; foreground = GRAY_TEXT),
        source_note                    = Face(; slant = :italic, foreground = GRAY_TEXT),
    ),
)

One Specification, Six Back Ends

# Terminal (text back end):
pretty_table(data; spec...)

# Markdown, LaTeX, Typst, and HTML:
pretty_table(String, data; backend = :markdown, spec...)
pretty_table(String, data; backend = :latex,    spec...)
pretty_table(String, data; backend = :typst,    spec...)
pretty_table(String, data; backend = :html,     spec..., stand_alone = true)

# Excel workbook (the Excel back end activates when XLSX.jl is loaded):
using XLSX
pretty_table(data; backend = :excel, spec..., filename = "showcase.xlsx")

Text back end (terminal):

Typst back end (compiled to PDF):

HTML back end:

Excel back end:

Notes

  • Every field of TableFormat and TableStyle defaults to nothing, which means “keep the back end default”. The objects are a sparse override: you only state what you want to change, and each back end keeps its native defaults for the rest.
  • The “Health” and “Life Used” columns show a useful pattern: store machine-readable values in the data (a Symbol state, a Float64 fraction), render them with formatters (status glyphs, a dot meter), and color them with value-matching Highlighters. All of it is backend agnostic.
  • The conversion is a best effort: aspects a back end cannot express are silently ignored. For example, the text back end maps line designs to Unicode box-drawing characters, Typst has no double stroke (it falls back to solid), and the LaTeX dashed lines require the arydshln package. The manual has a support matrix.
  • The backend-specific options are still available in the native table formats and styles if you need finer control over a single back end.

I am still making some decisions and fine tuning some aspects.

35 Likes