// HTML table rendering for `DataFrame`. The output is a standard
// `` — optional `class` / ``, a `` header row,
// a `` of data rows, and (when the row count is capped) a
// `` truncation banner — so it drops straight into a web page or
// a notebook's rich-display slot. Null cells render as an empty
// ` | `, delegating to `Scalar::to_string` (which maps `Null` to
// the empty string), so a null and a genuine empty-string cell are
// indistinguishable in the output (the renderer carries no null marker).
//
// Like `to_markdown`, this is a pure, dependency-free renderer that
// lives on `DataFrame` rather than in `io` (the IO boundary:
// rendering stays in `frame`, only external serialisation lives in
// `io`). Values are read column-major through `column_series()` /
// `to_scalars()`, exactly as `to_markdown` does, so a change to
// `Series::storage` is invisible here.
///|
/// Options for `DataFrame::to_html`. Fields are read-only outside the
/// package — build them through `HtmlOptions::HtmlOptions(...)`, naming only what differs
/// from the defaults:
///
/// * `max_rows` — cap the number of data rows. `None` (the default)
/// renders every row; `Some(n)` shows the first `n` and appends a
/// `` banner (`... (K more rows)`) for the remainder. A
/// negative `n` clamps to 0 (header + banner, no data rows),
/// matching `to_markdown`'s `max_rows`.
/// * `table_class` — value of the `` attribute.
/// `None` (the default) omits the attribute.
/// * `caption` — text of a leading `` element. `None` (the
/// default) omits it.
/// * `escape` — when `true` (the default) every emitted string —
/// caption, header names, cell values, and the class attribute — has
/// `&` / `<` / `>` / `"` / `'` rewritten to their HTML entities, so
/// untrusted data can't inject markup. Pass `escape=false` for
/// trusted input that intentionally carries HTML.
pub struct HtmlOptions {
max_rows : Int?
table_class : String?
caption : String?
escape : Bool
} derive(Eq, Debug)
///|
pub extend HtmlOptions with Eq::{equal, not_equal}
///|
pub extend HtmlOptions with Debug::{to_repr}
///|
/// Build render options. Omitting a parameter keeps its default: every row
/// rendered, no `class`, no ``, and HTML escaping on. `HtmlOptions::HtmlOptions()`
/// is the default rendering; name only what differs, as in
/// `HtmlOptions::HtmlOptions(max_rows=20, caption="Summary")`.
pub fn HtmlOptions::HtmlOptions(
max_rows? : Int,
table_class? : String,
caption? : String,
escape? : Bool = true,
) -> HtmlOptions {
{ max_rows, table_class, caption, escape }
}
///|
/// Render a `DataFrame` as an HTML ``. `options` defaults to
/// `HtmlOptions::HtmlOptions()` — all rows, no `class` / ``, HTML-escaped — and
/// otherwise supplies an optional `class` / ``, a row cap with a
/// `` `... (K more rows)` banner, and optional escaping.
///
/// The output is a `` with one `| ` per column followed by a
/// ` | ` with one `` per record (a `| ` per cell, in
/// declaration order); a null cell renders as ` | | `. `&` / `<` /
/// `>` / `"` / `'` are escaped to their HTML entities.
///
/// A frame with nothing to tabulate degrades gracefully:
/// - 0 columns → the empty string, at any height (MoonFrame's own choice for
/// a table with no cells to draw). This covers the `N×0` frame too, whose
/// rows are real (`is_empty()` is `false`) but carry no fields; `shape()`
/// is what reports the height of a column-less result;
/// - N columns / 0 rows → header with an empty `
`.
///
/// Total — pure string assembly over already-validated frame data, and
/// `options.max_rows` is clamped into `[0, nrows]`, so no input can fail.
pub fn DataFrame::to_html(
self : DataFrame,
options? : HtmlOptions = HtmlOptions::HtmlOptions(),
) -> String {
let nrows = self.nrows()
let shown = match options.max_rows {
None => nrows
Some(n) => @order.clamp_take(n, nrows)
}
format_html(self, shown, options)
}
// ── internals ──────────────────────────────────────────────────────────
///|
/// Render the first `shown` rows of `df` as an HTML table under
/// `options`. When `shown < df.nrows()` a `` banner reports the
/// hidden remainder. Unlike `to_markdown`'s banner (appended *after* the
/// table text), the HTML banner is a `` *inside* ``, so the
/// truncation decision is made here rather than in the public wrappers.
///
/// A 0-column frame renders as the empty string (there is no table to
/// draw); otherwise the header / `` are always emitted, so a
/// 0-row frame is still a useful header + empty ``.
fn format_html(df : DataFrame, shown : Int, options : HtmlOptions) -> String {
let names = df.columns()
let ncols = names.length()
if ncols == 0 {
return ""
}
let escape = options.escape
// Pre-render the visible cells once (raw strings, the column-major frame read
// transposed into per-row cells); each is HTML-escaped on write below.
let cells = table_cell_matrix(df, shown)
let buf = StringBuilder::new()
buf <+ "\n"
if options.caption is Some(text) {
buf <+ "\{html_escape(text, escape)}\n"
}
// Header.
buf <+ "\n"
for name in names {
buf <+ "| \{html_escape(name, escape)} | "
}
buf <+ "
\n\n"
// Body — one `` per visible record. A null cell renders empty
// (`Scalar::to_string` maps `Null` to "").
buf <+ "
\n"
for r in 0.."
for c in 0..\{html_escape(cells[r][c], escape)}"
}
buf <+ "\n"
}
buf <+ "\n"
// Truncation banner, spanning every column. The banner text is fixed
// (digits + ASCII), so it needs no escaping.
let nrows = df.nrows()
if shown < nrows {
buf <+
"\n| \{more_rows_banner(nrows - shown)} |
\n\n"
}
buf <+ "
\n"
buf.to_string()
}
///|
/// HTML-escape `s` when `escape` is `true`: `&` → `&`, `<` → `<`,
/// `>` → `>`, `"` → `"`, `'` → `'`. Processing
/// codepoint-by-codepoint means the `&` rule can't double-escape the `&` it
/// just introduced. When `escape` is `false`, `s` is returned verbatim
/// (trusted input). The full conventional set (including `'`) is escaped, so
/// a cell or caption is safe in both `"`-quoted and `'`-quoted attribute
/// contexts, not only the `>`-delimited text the renderer emits today.
fn html_escape(s : String, escape : Bool) -> String {
if !escape {
return s
}
let buf = StringBuilder::new()
for ch in s.iter() {
match ch {
'&' => buf.write_string("&")
'<' => buf.write_string("<")
'>' => buf.write_string(">")
'"' => buf.write_string(""")
'\'' => buf.write_string("'")
_ => buf.write_char(ch)
}
}
buf.to_string()
}