[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.

Holy hell those are some good looking tables!

Nice, TimerOutputs will be ready for it (Add formatted timer exports for PrettyTables backends - Pull Request #240 - KristofferC/TimerOutputs.jl - GitHub)

HTML:

Typst:

Excel:

DataBars are also possible in Excel, too, using setConditionalFormat in XLSX.jl - though not directly through PrettyTables, I don’t think.

Looks really nice. Is there some possibility to specify that floats should be presented in either scientific format (e.g., 1e5 or engineering format (e.g., 100e3)? And to typeset these in LaTeX or whatever?

Yes! You can use a formatter to change the output of any cell you want.

HI,
A newby here, so please bear with me, I added the package and pasted the code above to try and running, however I’m getting an error message:

ERROR: LoadError: UndefVarError: `@styled_str` not defined in `Main`
Suggestion: check for spelling errors or missing imports.
Hint: a global variable of this name also exists in StyledStrings.StyledMarkup.
    - Also exported by StyledStrings (loaded but not imported in Main).
in expression starting at REPL[17]:28

Line 17 if I’m not mistaken is about initializing the satelites list
"Meridian-1", "Meridian-2", "Meridian-3",
So any help is appreciated. In principle, I’m only interested in displaying the table in the terminal, so the rest of outputs aren’t as important (for now).

Thanks!

Hi @orlando_mendez !

The version is not released yet. So you need to clone the dev version. Type in Julia REPL ]dev PrettyTables. Then, restart Julia and try again :slight_smile:

Any plans for Microsoft Word backend?
Or do the Excel tables copy decently to docx files?

I also needed a docx backend a while ago and created StyledTables.jl on top of SummaryTables (this started pre-LLM but Claude eventually allowed me to more cleanly package the code I had lying around). And it of course offers much fewer features than PrettyTabes.jl, but it’s so far served my docx-hungry collaborators well.

And super cool new features @Ronis_BR, the tables all look so beautiful!

The Excel version will easily cut and paste into Word. I just used ctrl-c / ctrl-v. It pastes as a Word table, so is fully editable after pasting.

This image clipped directly from Word.

I’ll see if I can make a PR to add WriteDocx to the matrix here. It has more features than SummaryTables uses (that’s mostly due to the very conservative styling requirements in the pharma space, which is why SummaryTables is very opinionated and doesn’t do colored cell backgrounds for example).