R/melliotab.R

Defines functions format_table_data console_key_value_row console_key_value_table frequency_table_data summary_default_table melliotab.data.frame melliotab.table melliotab.summaryDefault melliotab.character melliotab.NULL melliotab.default mellio_is_model_comparison_object mellio_model_comparison_table mellio_should_build_model_comparison_table melliotab

Documented in format_table_data melliotab melliotab.character melliotab.data.frame melliotab.default melliotab.NULL melliotab.summaryDefault melliotab.table

#' Create a formatted statistical table
#'
#' The main entry point for melliotab. Accepts data frames, model objects,
#' or correlation matrices and formats them according to the specified
#' citation style. Passing two or more model objects creates a side-by-side
#' model comparison table.
#'
#' @param x A data.frame, model object (lm, glm, aov, htest), or correlation matrix
#' @param section Optional section selector for multi-section tables. Examples
#'   include `"fit"`, `"loadings"`, `"paths"`, `"covariances"`, `"defined"`,
#'   `"reliability"`, or `"modification_indices"` for structural model
#'   payloads; `"loadings"`, `"variance"`, or `"fit"` for EFA; and
#'   `"comparison"`, `"fit"`, or `"diff"` for FitDiff objects.
#' @param what Backward-compatible alias for `section` in methods that
#'   previously used `what`. Prefer `section`.
#' @param ... Additional arguments passed to methods
#' @return A melliotab object
#' @export
#' @section Supported inputs:
#' `melliotab()` is intended for table output inside R. It supports plain data
#' frames, matrices, base `table` objects, correlation matrices, common model
#' objects, hypothesis-test objects, and payloads created by `mellio_payload()`.
#' For objects that can produce several tables, call `melliotab(x)` once to
#' see the available `section` choices, then request one explicitly, for
#' example `melliotab(fit, section = "loadings")`.
#'
#' @section Common modifiers:
#' The `mt_*()` helpers are optional table modifiers. They follow the same
#' pattern as many R table packages: create a table once, then add formatting
#' only where needed. Common helpers include `mt_title()`, `mt_note()`,
#' `mt_decimals()`, `mt_format_ci()`, `mt_remove_leading_zeros()`,
#' `mt_sig_stars()`, `mt_spanner()`, `mt_indent()`, and
#' `mt_section_title()`.
#'
#' Significance stars are never added by default. Use `mt_sig_stars()` only
#' when that convention is appropriate for your manuscript, course, or journal.
#'
#' @examples
#' # From a data frame
#' df <- data.frame(
#'   Variable = c("Age", "Gender"),
#'   B = c(0.45, -1.23),
#'   SE = c(0.12, 0.34),
#'   t = c(3.75, -3.62),
#'   p = c(0.0003, 0.0004)
#' )
#' melliotab(df, style = "apa7", title = "Regression Results")
#'
#' # From a linear model
#' model <- lm(Ozone ~ Temp + Wind, data = airquality)
#' melliotab(model, style = "apa7", title = "Predictors of ozone concentration")
#'
#' # Compare multiple models side by side
#' m1 <- lm(Ozone ~ Temp, data = airquality)
#' m2 <- lm(Ozone ~ Temp + Wind, data = airquality)
#' m3 <- lm(Ozone ~ Temp + Wind + Solar.R, data = airquality)
#' melliotab(
#'   m1, m2, m3,
#'   labels = c("Step 1", "Step 2", "Step 3"),
#'   dep.var.labels = "Ozone concentration"
#' )
melliotab <- function(x, ...) {
  dots <- list(...)
  if (mellio_should_build_model_comparison_table(x, dots)) {
    return(mellio_model_comparison_table(x, dots))
  }

  UseMethod("melliotab")
}

mellio_should_build_model_comparison_table <- function(x, dots) {
  if (!mellio_is_model_comparison_object(x) || !length(dots)) return(FALSE)

  dot_names <- names(dots) %||% rep("", length(dots))
  unnamed <- !nzchar(dot_names)
  any(unnamed & vapply(dots, mellio_is_model_comparison_object, logical(1)))
}

mellio_model_comparison_table <- function(x, dots) {
  model_count <- 1L + sum(vapply(dots, mellio_is_model_comparison_object, logical(1)))
  if (!is.null(dots$labels)) {
    if (is.null(dots$column.labels)) dots$column.labels <- dots$labels
    dots$labels <- NULL
  }
  if (is.null(dots$title)) {
    dots$title <- paste0("Model comparison (", model_count, " models)")
  }
  do.call(mt_compare, c(list(x), dots))
}

mellio_is_model_comparison_object <- function(x) {
  if (inherits(x, c(
    "lm", "glm", "aov", "coxph", "survreg", "lme", "gls",
    "merMod", "lmerMod", "glmerMod", "polr", "multinom",
    "gam", "gee", "geeglm", "negbin", "zeroinfl"
  ))) {
    return(TRUE)
  }

  if (inherits(x, c(
    "formula", "data.frame", "matrix", "table", "htest", "character",
    "melliotab", "mellio_payload"
  ))) {
    return(FALSE)
  }

  f <- tryCatch(stats::formula(x), error = function(e) NULL)
  !is.null(f) && length(f) >= 2L
}

#' @rdname melliotab
#' @export
melliotab.default <- function(x, ..., section = NULL) {
  if (is.data.frame(x)) {
    return(melliotab.data.frame(x, ...))
  }

  cls <- class(x)
  payload_method <- NULL
  for (class_name in cls) {
    payload_method <- utils::getS3method("mellio_payload", class_name, optional = TRUE)
    if (!is.null(payload_method)) break
  }

  if (!is.null(payload_method)) {
    payload <- mellio_payload(x, ...)
    return(melliotab_from_payload(payload, section = section, ...))
  }

  fallback_payload <- tryCatch(
    mellio_payload(x, ...),
    error = function(e) NULL
  )
  if (inherits(fallback_payload, "mellio_payload") &&
      !fallback_payload$card_kind %in% c("unsupported", "raw_text")) {
    return(melliotab_from_payload(fallback_payload, section = section, ...))
  }

  cli::cli_abort(c(
    "melliotab does not know how to handle objects of class {.cls {class(x)}}.",
    "i" = "Tip: paste the printed output into Tables in Mellio."
  ))
}

#' @rdname melliotab
#' @export
melliotab.NULL <- function(x, ...) {
  cli::cli_abort(c(
    "{.fun melliotab} received {.code NULL}.",
    "i" = "This usually means the R code printed output but did not return a table or model object.",
    "i" = "Return a {.cls data.frame}, {.cls table}, {.fun summary}, or supported model object instead of only calling {.fun print} or {.fun cat}.",
    "i" = "To keep simple printed loop output, wrap the loop in {.fun capture.output} and pass those lines to {.fun mellio_open}."
  ))
}

#' @rdname melliotab
#' @export
melliotab.character <- function(x, style = "apa7", title = NULL,
                                number = NULL, note = NULL,
                                source = NULL, decimals = 2L,
                                p_decimals = 3L, ...) {
  df <- console_key_value_table(x)
  if (is.null(df)) {
    cli::cli_abort(c(
      "{.fun melliotab} could not parse character input as a table.",
      "i" = "Pass a {.cls data.frame} when possible.",
      "i" = "For printed loop output, use lines like {.code variable r = .12, p = .034}."
    ))
  }
  if (is.null(title)) title <- "Captured console output"
  if (is.null(source)) source <- "capture.output()"

  result <- melliotab.data.frame(
    df, style = style, title = title,
    number = number, note = note,
    source = source, decimals = decimals,
    p_decimals = p_decimals, ...
  )
  result$model <- x
  result
}

#' @rdname melliotab
#' @export
melliotab.summaryDefault <- function(x, style = "apa7", title = NULL,
                                     number = NULL, note = NULL,
                                     source = NULL, decimals = 2L,
                                     p_decimals = 3L, ...) {
  df <- summary_default_table(x)
  if (is.null(title)) title <- "Summary statistics"
  if (is.null(source)) source <- "base R summary()"

  result <- melliotab.data.frame(
    df, style = style, title = title,
    number = number, note = note,
    source = source, decimals = decimals,
    p_decimals = p_decimals, ...
  )
  result$model <- x
  result
}

#' @rdname melliotab
#' @export
melliotab.table <- function(x, style = "apa7", title = NULL,
                            number = NULL, note = NULL,
                            source = NULL, decimals = 2L,
                            p_decimals = 3L, ...) {
  df <- frequency_table_data(x)
  if (is.null(title)) title <- "Frequency table"
  if (is.null(source)) source <- "base R table()"

  result <- melliotab.data.frame(
    df, style = style, title = title,
    number = number, note = note,
    source = source, decimals = decimals,
    p_decimals = p_decimals, ...
  )
  result$model <- x
  result
}

#' @rdname melliotab
#' @param style Citation style: "apa7" or "ieee"
#' @param title Table title
#' @param number Table number (integer or character)
#' @param note Table note text
#' @param source Table source text
#' @param decimals Decimal places for estimates/statistics (1-4)
#' @param p_decimals Decimal places for p-values (2-4)
#' @export
melliotab.data.frame <- function(x, style = "apa7", title = NULL,
                                  number = NULL, note = NULL,
                                  source = NULL, decimals = 2L,
                                  p_decimals = 3L, ...) {
  style <- match.arg(style, list_styles())
  style_config <- get_style(style)

  # Store original data for re-styling

  raw_data <- x

  # Detect column types
  col_types <- detect_column_types(names(x))

  # Determine if leading zero removal applies based on style
  remove_lz <- isTRUE(style_config$remove_leading_zeros)
  lz_cols <- detect_leading_zero_cols(names(x))

  # Format the data
  formatted <- format_table_data(x, col_types, lz_cols,
                                  decimals = decimals,
                                  p_decimals = p_decimals,
                                  remove_lz = remove_lz,
                                  style_config = style_config)

  # Build the melliotab object
  structure(
    list(
      data = formatted,
      raw_data = raw_data,
      column_types = col_types,
      style = style,
      style_config = style_config,
      title = title,
      number = number,
      note = note,
      source = source,
      spanners = list(),
      section_titles = list(),
      merged_regions = list(),
      indent_levels = integer(0),
      decimals = as.integer(decimals),
      p_decimals = as.integer(p_decimals),
      options = list(
        remove_leading_zeros = remove_lz,
        italic_stat_headers = isTRUE(style_config$italic_stat_headers),
        bold_section_titles = isTRUE(style_config$bold_section_titles),
        sig_stars = FALSE,
        format_ci = FALSE,
        diagonal_mode = "all",
        triangle = "all",
        is_correlation = FALSE
      ),
      model = NULL,
      model_summary = NULL
    ),
    class = "melliotab"
  )
}

summary_default_table <- function(x) {
  values <- as.numeric(unname(x))
  labels <- names(x)
  if (is.null(labels) || length(labels) != length(values)) {
    labels <- paste0("Value ", seq_along(values))
  }

  clean <- vapply(labels, function(label) {
    if (ms_summary_missing_label(label)) return("Missing")
    switch(label,
      "Min." = "Min",
      "1st Qu." = "Q1",
      "Median" = "Median",
      "Mean" = "Mean",
      "3rd Qu." = "Q3",
      "Max." = "Max",
      label
    )
  }, character(1), USE.NAMES = FALSE)

  df <- as.data.frame(as.list(values), check.names = FALSE)
  names(df) <- clean
  if ("Missing" %in% names(df)) df[["Missing"]] <- as.integer(df[["Missing"]])
  df
}

frequency_table_data <- function(x) {
  df <- as.data.frame(x, responseName = "n", stringsAsFactors = FALSE)
  nms <- names(df)
  dim_names <- names(dimnames(x))
  value_cols <- setdiff(seq_along(nms), length(nms))

  for (i in value_cols) {
    dim_label <- if (length(dim_names) >= i && nzchar(dim_names[[i]])) {
      dim_names[[i]]
    } else if (length(value_cols) == 1L) {
      "Category"
    } else {
      paste0("Variable ", i)
    }
    names(df)[[i]] <- dim_label
  }

  df[["n"]] <- as.integer(df[["n"]])
  df
}

console_key_value_table <- function(x) {
  lines <- unlist(strsplit(paste(x, collapse = "\n"), "\n", fixed = TRUE),
                  use.names = FALSE)
  lines <- trimws(lines)
  lines <- lines[nzchar(lines)]
  if (length(lines) == 0L) return(NULL)

  parsed <- lapply(lines, console_key_value_row)
  if (any(vapply(parsed, is.null, logical(1)))) return(NULL)

  keys <- unique(unlist(lapply(parsed, function(row) names(row$values)),
                        use.names = FALSE))
  if (length(keys) == 0L) return(NULL)

  out <- data.frame(Variable = vapply(parsed, `[[`, character(1), "label"),
                    stringsAsFactors = FALSE, check.names = FALSE)
  for (key in keys) {
    out[[key]] <- vapply(parsed, function(row) {
      row$values[[key]] %||% NA_real_
    }, numeric(1))
  }
  out
}

console_key_value_row <- function(line) {
  number <- "[+-]?(?:\\d+\\.?\\d*|\\.\\d+)(?:[eE][+-]?\\d+)?"
  pattern <- paste0("([[:alpha:].][[:alnum:]_.]*)\\s*=\\s*(", number, ")")
  hits <- gregexpr(pattern, line, perl = TRUE)[[1]]
  if (identical(hits[[1]], -1L)) return(NULL)

  starts <- as.integer(hits)
  lens <- attr(hits, "match.length")
  first <- starts[[1]]
  label <- trimws(substr(line, 1L, first - 1L))
  label <- trimws(gsub("[,;:]+$", "", label))
  if (!nzchar(label)) return(NULL)

  values <- list()
  for (i in seq_along(starts)) {
    txt <- substr(line, starts[[i]], starts[[i]] + lens[[i]] - 1L)
    parts <- regmatches(txt, regexec(pattern, txt, perl = TRUE))[[1]]
    key <- parts[[2]]
    value <- suppressWarnings(as.numeric(parts[[3]]))
    if (is.na(value)) return(NULL)
    values[[key]] <- value
  }

  list(label = label, values = values)
}

#' Format table data according to APA/style rules
#'
#' @keywords internal
format_table_data <- function(data, col_types, lz_cols, decimals, p_decimals,
                               remove_lz, style_config) {
  formatted <- data

  for (j in seq_along(col_types)) {
    ct <- col_types[j]
    if (ct == "stub" || ct == "default") {
      if (is.numeric(formatted[[j]])) {
        formatted[[j]] <- formatC(as.numeric(formatted[[j]]),
                                   format = "f", digits = decimals)
      }
      next
    }

    # Convert column to character for formatting
    vals <- as.character(formatted[[j]])

    # Apply APA number formatting
    vals <- vapply(vals, function(v) {
      format_apa_number(v, ct, decimals = decimals, p_decimals = p_decimals,
                         remove_lz = remove_lz)
    }, character(1), USE.NAMES = FALSE)

    formatted[[j]] <- vals
  }

  # Apply leading zero removal to bounded stat columns
  if (remove_lz) {
    for (j in seq_along(lz_cols)) {
      if (lz_cols[j] && col_types[j] != "pvalue") {
        formatted[[j]] <- vapply(as.character(formatted[[j]]),
                                  remove_leading_zero,
                                  character(1), USE.NAMES = FALSE)
      }
    }
  }

  # Optional thousands/parenthetical number formatting
  if (isTRUE(style_config$thousands_separator)) {
    use_parens <- isTRUE(style_config$parenthetical_negatives)
    for (j in seq_along(col_types)) {
      if (col_types[j] == "stub") next
      formatted[[j]] <- vapply(as.character(formatted[[j]]),
                                function(v) format_business_number(v, use_parens),
                                character(1), USE.NAMES = FALSE)
    }
  }

  # Ensure all columns are character
  for (j in seq_along(formatted)) {
    formatted[[j]] <- as.character(formatted[[j]])
  }

  formatted
}

Try the mellio package in your browser

Any scripts or data that you put into this service are public.

mellio documentation built on Aug. 30, 2026, 1:06 a.m.