R/tabforest.R

Defines functions as.data.frame.r4vn_tabforest print.r4vn_tabforest plot.r4vn_tabforest_subgroup plot.r4vn_tabforest_multi plot.r4vn_tabforest .r4vn_tf_draw_ci .r4vn_tf_draw_axis .r4vn_tf_open_plot .r4vn_tf_refresh_settings tabforest .r4vn_tf_subgroup_publication .r4vn_tf_subgroup_build .r4vn_tf_subgroup_adjust_spec .r4vn_tf_interaction_p .r4vn_tf_fit_interaction .r4vn_tf_formula_interaction .r4vn_tf_outcome_specs .r4vn_tf_panel_value .r4vn_tf_device .r4vn_tf_axis_range .r4vn_tf_numeric_text .r4vn_tf_draw_zebra .r4vn_tf_row_positions .r4vn_tf_expand_rows .r4vn_tf_wrap .r4vn_tf_resolve_named .r4vn_tf_publication_data .r4vn_tf_text_full .r4vn_tf_fmt_p .r4vn_tf_fmt_num .r4vn_tf_object .r4vn_tf_rows_from_estimates .r4vn_tf_from_model .r4vn_tf_fit_spec .r4vn_tf_build_raw .r4vn_tf_rows .r4vn_tf_text .r4vn_tf_effect_type .r4vn_tf_one_effect .r4vn_tf_global_p .r4vn_tf_coef_names .r4vn_tf_term_index .r4vn_tf_fit .r4vn_tf_formula .r4vn_tf_robust_poisson .r4vn_tf_prepare .r4vn_tf_per_label .r4vn_tf_per .r4vn_tf_level_label .r4vn_tf_var_label .r4vn_tf_ref .r4vn_tf_merge_spec .r4vn_tf_spec .r4vn_tf_levels .r4vn_tf_name .r4vn_tf_data .r4vn_tf_escape_name

Documented in plot.r4vn_tabforest tabforest

# =============================================================================
# R4VN tabforest() - regression forest table/plot engine
# =============================================================================

.r4vn_tf_escape_name <- function(x) {
  x <- as.character(x)
  ifelse(make.names(x) == x, x, paste0("`", gsub("`", "", x, fixed = TRUE), "`"))
}

.r4vn_tf_data <- function(data = NULL) {
  if (!is.null(data)) {
    if (!is.data.frame(data)) stop("`data` must be a data frame.", call. = FALSE)
    return(data)
  }

  # Use the same active-data resolver as the current R4VN analysis commands.
  if (exists(".r4vn_resolve_analysis_data", mode = "function", inherits = TRUE)) {
    z <- tryCatch(
      get(".r4vn_resolve_analysis_data", mode = "function", inherits = TRUE)(NULL),
      error = function(e) NULL
    )
    if (is.data.frame(z)) return(z)
  }
  if (exists(".r4vn_get_active", mode = "function", inherits = TRUE)) {
    f <- get(".r4vn_get_active", mode = "function", inherits = TRUE)
    z <- tryCatch(f(required = FALSE), error = function(e) tryCatch(f(), error = function(e2) NULL))
    if (is.data.frame(z)) return(z)
  }

  # Backward-compatible fallbacks for older development builds.
  if (exists("active_data", mode = "function", inherits = TRUE)) {
    f <- get("active_data", mode = "function", inherits = TRUE)
    z <- tryCatch(f(), error = function(e) NULL)
    if (is.data.frame(z)) return(z)
  }
  nm <- getOption(".r4vn_active_data_name")
  if (is.character(nm) && length(nm) == 1L && nzchar(nm)) {
    z <- tryCatch(get(nm, envir = .GlobalEnv, inherits = TRUE), error = function(e) NULL)
    if (is.data.frame(z)) return(z)
  }
  stop("No data frame was supplied and no active R4VN data frame is available. Use `data = ...` or `usedf()` first.", call. = FALSE)
}

.r4vn_tf_name <- function(expr, data, env, arg, optional = FALSE) {
  if (identical(expr, quote(NULL))) {
    if (optional) return(NULL)
    stop(sprintf("`%s` is required.", arg), call. = FALSE)
  }
  if (is.symbol(expr)) {
    nm <- as.character(expr)
    if (nm %in% names(data)) return(nm)
  }
  value <- tryCatch(eval(expr, envir = env), error = function(e) NULL)
  if (is.character(value) && length(value) == 1L && value %in% names(data)) return(value)
  raw <- paste(deparse(expr, width.cutoff = 500L), collapse = "")
  if (raw %in% names(data)) return(raw)
  stop(sprintf("`%s` must identify one variable in `data`.", arg), call. = FALSE)
}

.r4vn_tf_levels <- function(x) {
  observed <- x[!is.na(x)]
  if (is.factor(x)) {
    lev <- levels(x)
    return(lev[lev %in% as.character(observed)])
  }
  if (is.logical(x)) return(as.character(c(FALSE, TRUE)[c(FALSE, TRUE) %in% observed]))
  z <- unique(as.character(observed))
  if (is.numeric(x)) z <- as.character(sort(unique(x[!is.na(x)])))
  z
}

.r4vn_tf_spec <- function(x, data, arg = "predictors", allow_null = FALSE) {
  if (is.null(x) || identical(x, FALSE)) {
    if (allow_null) return(NULL)
    stop(sprintf("`%s` is required.", arg), call. = FALSE)
  }
  if (inherits(x, "r4vn_vars")) {
    resolver <- get0(".r4vn_resolve_vars", mode = "function", inherits = TRUE)
    out <- if (is.function(resolver)) {
      as.data.frame(resolver(x, data = data, default_type = "auto", strict = TRUE), stringsAsFactors = FALSE)
    } else {
      as.data.frame(x, stringsAsFactors = FALSE)
    }
  } else if (is.character(x)) {
    out <- lapply(x, function(v) {
      if (!v %in% names(data)) stop(sprintf("Variable `%s` in `%s` was not found in `data`.", v, arg), call. = FALSE)
      is_cont <- is.numeric(data[[v]])
      data.frame(variable = v,
                 type = if (is_cont) "mean" else "categorical",
                 specification = v,
                 reference_index = if (is_cont) NA_integer_ else 1L,
                 stringsAsFactors = FALSE)
    })
    out <- do.call(rbind, out)
  } else {
    stop(sprintf("`%s` must be created by `vars()` or supplied as a character vector of variable names.", arg), call. = FALSE)
  }
  need <- c("variable", "type", "specification", "reference_index")
  if (!all(need %in% names(out))) stop(sprintf("`%s` is not a valid R4VN variable specification.", arg), call. = FALSE)
  if (any(!out$variable %in% names(data))) {
    stop(sprintf("Variable(s) not found in `data`: %s.", paste(out$variable[!out$variable %in% names(data)], collapse = ", ")), call. = FALSE)
  }
  out <- out[!duplicated(out$variable), need, drop = FALSE]
  rownames(out) <- NULL
  out
}

.r4vn_tf_merge_spec <- function(...) {
  z <- Filter(function(x) !is.null(x) && nrow(x), list(...))
  if (!length(z)) return(NULL)
  out <- do.call(rbind, z)
  out <- out[!duplicated(out$variable, fromLast = TRUE), , drop = FALSE]
  rownames(out) <- NULL
  out
}

.r4vn_tf_ref <- function(x, spec_row) {
  lev <- .r4vn_tf_levels(x)
  idx <- suppressWarnings(as.integer(spec_row$reference_index[1L]))
  if (!length(lev)) return(NULL)
  if (is.na(idx)) idx <- 1L
  if (idx < 1L || idx > length(lev)) {
    stop(sprintf("Reference level b%s is invalid for `%s`, which has %s observed level(s).",
                 idx, spec_row$variable[1L], length(lev)), call. = FALSE)
  }
  lev[idx]
}

.r4vn_tf_var_label <- function(data, variable, labels = NULL) {
  if (!is.null(labels) && !is.null(names(labels)) && variable %in% names(labels)) {
    z <- as.character(labels[[variable]])[1L]
    if (nzchar(z)) return(z)
  }
  z <- attr(data[[variable]], "label", exact = TRUE)
  if (!is.null(z) && length(z) && nzchar(as.character(z)[1L])) return(as.character(z)[1L])
  variable
}

.r4vn_tf_level_label <- function(variable, level, level_labels = NULL) {
  if (!is.null(level_labels) && !is.null(level_labels[[variable]])) {
    z <- level_labels[[variable]]
    if (!is.null(names(z)) && level %in% names(z)) return(as.character(z[[level]])[1L])
  }
  as.character(level)
}

.r4vn_tf_per <- function(variable, per = NULL) {
  if (is.null(per)) return(1)
  if (length(per) == 1L && is.null(names(per))) return(as.numeric(per)[1L])
  if (!is.null(names(per)) && variable %in% names(per)) return(as.numeric(per[[variable]])[1L])
  1
}

.r4vn_tf_per_label <- function(variable, value, per_labels = NULL, lang = "en") {
  if (!is.null(per_labels) && !is.null(names(per_labels)) && variable %in% names(per_labels)) {
    return(as.character(per_labels[[variable]])[1L])
  }
  if (!is.finite(value) || value == 1) return(NULL)
  if (identical(lang, "vi")) paste0("m\u1ed7i ", format(value, trim = TRUE, scientific = FALSE), " \u0111\u01a1n v\u1ecb")
  else paste0("per ", format(value, trim = TRUE, scientific = FALSE), " units")
}

.r4vn_tf_prepare <- function(data, outcome_name, time_name, status_name,
                             model_spec, effect, event = NULL, failure = NULL) {
  vars <- if (is.null(model_spec)) character() else model_spec$variable
  needed <- unique(c(outcome_name, time_name, status_name, vars))
  needed <- needed[!is.na(needed) & nzchar(needed)]
  d <- data[, needed, drop = FALSE]
  keep <- stats::complete.cases(d)
  d <- d[keep, , drop = FALSE]
  if (!nrow(d)) stop("No complete observations are available for this model.", call. = FALSE)

  if (identical(effect, "HR")) {
    d$.time <- suppressWarnings(as.numeric(d[[time_name]]))
    status <- d[[status_name]]
    lev <- .r4vn_tf_levels(status)
    if (is.null(failure)) {
      if (is.numeric(status) && any(status == 1, na.rm = TRUE)) failure <- 1
      else failure <- tail(lev, 1L)
    }
    d$.status <- as.integer(as.character(status) == as.character(failure))
    if (any(!is.finite(d$.time)) || any(d$.time < 0)) stop("Survival time must be non-negative and finite.", call. = FALSE)
    if (length(unique(d$.status)) < 2L) stop("The survival status contains fewer than two event states in the analysis sample.", call. = FALSE)
  } else if (effect %in% c("OR", "RR", "PR")) {
    y <- d[[outcome_name]]
    lev <- .r4vn_tf_levels(y)
    if (length(lev) != 2L) stop(sprintf("%s requires a binary outcome.", effect), call. = FALSE)
    if (is.null(event)) event <- tail(lev, 1L)
    if (!as.character(event) %in% lev) stop("`event` is not an observed level of the outcome.", call. = FALSE)
    d$.outcome <- as.integer(as.character(y) == as.character(event))
  } else if (identical(effect, "IRR")) {
    y <- suppressWarnings(as.numeric(d[[outcome_name]]))
    if (any(!is.finite(y)) || any(y < 0)) stop("IRR requires a non-negative count outcome.", call. = FALSE)
    if (any(abs(y - round(y)) > sqrt(.Machine$double.eps))) stop("IRR requires integer count values.", call. = FALSE)
    d$.outcome <- y
  } else {
    y <- suppressWarnings(as.numeric(d[[outcome_name]]))
    if (any(!is.finite(y))) stop("Beta regression requires a numeric continuous outcome.", call. = FALSE)
    d$.outcome <- y
  }

  if (!is.null(model_spec) && nrow(model_spec)) {
    for (i in seq_len(nrow(model_spec))) {
      v <- model_spec$variable[i]
      if (identical(model_spec$type[i], "categorical")) {
        lev <- .r4vn_tf_levels(data[[v]])
        ref <- .r4vn_tf_ref(data[[v]], model_spec[i, , drop = FALSE])
        d[[v]] <- factor(d[[v]], levels = lev)
        d[[v]] <- stats::relevel(d[[v]], ref = ref)
        if (nlevels(droplevels(d[[v]])) < 2L) stop(sprintf("Categorical variable `%s` has fewer than two observed levels in this model.", v), call. = FALSE)
      } else {
        d[[v]] <- suppressWarnings(as.numeric(d[[v]]))
        if (!is.finite(stats::sd(d[[v]])) || stats::sd(d[[v]]) == 0) stop(sprintf("Continuous variable `%s` has no variation in this model.", v), call. = FALSE)
      }
    }
  }
  attr(d, "r4vn_keep") <- which(keep)
  attr(d, "r4vn_event") <- event
  attr(d, "r4vn_failure") <- failure
  d
}

.r4vn_tf_robust_poisson <- function(fit) {
  X <- stats::model.matrix(fit)
  mu <- stats::fitted(fit)
  y <- fit$y
  if (is.null(y)) y <- tryCatch(stats::model.response(stats::model.frame(fit)), error = function(e) NULL)
  if (is.null(y)) stop("The Poisson model does not retain a usable response.", call. = FALSE)
  r <- y - mu
  bread <- tryCatch(solve(crossprod(X, X * as.vector(mu))), error = function(e) NULL)
  if (is.null(bread)) bread <- tryCatch(qr.solve(crossprod(X, X * as.vector(mu))), error = function(e) NULL)
  if (is.null(bread)) stop("The robust covariance matrix could not be estimated.", call. = FALSE)
  meat <- crossprod(X, X * as.vector(r^2))
  V <- bread %*% meat %*% bread
  dimnames(V) <- list(colnames(X), colnames(X))
  V
}

.r4vn_tf_formula <- function(response, variables) {
  terms <- vapply(variables, .r4vn_tf_escape_name, character(1))
  stats::as.formula(paste(response, "~", if (length(terms)) paste(terms, collapse = " + ") else "1"))
}

.r4vn_tf_fit <- function(d, model_spec, effect, ci = .95) {
  variables <- if (is.null(model_spec)) character() else model_spec$variable
  if (identical(effect, "HR")) {
    if (!requireNamespace("survival", quietly = TRUE)) {
      stop("Cox forest plots require the `survival` package. Install it with install.packages('survival').", call. = FALSE)
    }
    f <- .r4vn_tf_formula("survival::Surv(.time, .status)", variables)
    fit <- survival::coxph(f, data = d, x = TRUE, y = TRUE, model = TRUE, ties = "efron")
    V <- stats::vcov(fit)
    family <- "cox"
  } else if (identical(effect, "Beta")) {
    f <- .r4vn_tf_formula(".outcome", variables)
    fit <- stats::lm(f, data = d, x = TRUE, y = TRUE)
    V <- stats::vcov(fit)
    family <- "lm"
  } else if (identical(effect, "OR")) {
    f <- .r4vn_tf_formula(".outcome", variables)
    fit <- stats::glm(f, family = stats::binomial("logit"), data = d, x = TRUE, y = TRUE)
    V <- stats::vcov(fit)
    family <- "glm"
  } else {
    f <- .r4vn_tf_formula(".outcome", variables)
    fit <- stats::glm(f, family = stats::poisson("log"), data = d, x = TRUE, y = TRUE)
    V <- if (effect %in% c("RR", "PR")) .r4vn_tf_robust_poisson(fit) else stats::vcov(fit)
    family <- if (effect %in% c("RR", "PR")) "poisson_robust" else "poisson"
  }
  list(fit = fit, vcov = V, family = family, data = d, spec = model_spec, ci = ci)
}

.r4vn_tf_term_index <- function(fit, variable) {
  tt <- attr(stats::terms(fit), "term.labels")
  clean <- gsub("`", "", tt, fixed = TRUE)
  match(variable, clean)
}

.r4vn_tf_coef_names <- function(fit, variable) {
  idx <- .r4vn_tf_term_index(fit, variable)
  if (is.na(idx)) return(character())
  mm <- stats::model.matrix(fit)
  ass <- attr(mm, "assign")
  colnames(mm)[ass == idx]
}

.r4vn_tf_global_p <- function(model, variable) {
  b <- stats::coef(model$fit)
  cn <- intersect(.r4vn_tf_coef_names(model$fit, variable), names(b))
  cn <- cn[is.finite(b[cn])]
  if (!length(cn)) return(NA_real_)
  V <- model$vcov[cn, cn, drop = FALSE]
  good <- is.finite(b[cn]) & is.finite(diag(V)) & diag(V) > 0
  cn <- cn[good]
  if (!length(cn)) return(NA_real_)
  b <- b[cn]
  V <- V[cn, cn, drop = FALSE]
  inv <- tryCatch(solve(V), error = function(e) tryCatch(qr.solve(V), error = function(e2) NULL))
  if (is.null(inv)) return(NA_real_)
  q <- as.numeric(t(b) %*% inv %*% b)
  df <- qr(V)$rank
  if (!is.finite(q) || df < 1L) return(NA_real_)
  stats::pchisq(q, df = df, lower.tail = FALSE)
}

.r4vn_tf_one_effect <- function(model, variable, spec_row, effect, per = 1,
                                ci = .95, model_name = "Crude") {
  fit <- model$fit
  V <- model$vcov
  b <- stats::coef(fit)
  cn <- intersect(.r4vn_tf_coef_names(fit, variable), names(b))
  alpha <- 1 - ci
  crit <- if (inherits(fit, "lm")) stats::qt(1 - alpha / 2, df = stats::df.residual(fit)) else stats::qnorm(1 - alpha / 2)
  n <- stats::nobs(fit)
  events <- if (identical(effect, "HR") && ".status" %in% names(model$data)) {
    sum(model$data$.status == 1L)
  } else if (effect %in% c("OR", "RR", "PR") && ".outcome" %in% names(model$data)) {
    sum(model$data$.outcome == 1L)
  } else NA_real_
  gp <- .r4vn_tf_global_p(model, variable)

  if (identical(spec_row$type[1L], "categorical")) {
    lev <- levels(model$data[[variable]])
    ref <- lev[1L]
    nonref <- lev[-1L]
    out <- data.frame(variable = variable, level = lev, reference = lev == ref,
                      model = model_name, estimate = NA_real_, lower = NA_real_, upper = NA_real_,
                      p = NA_real_, global_p = gp, n = n, events = events,
                      stringsAsFactors = FALSE)
    if (effect %in% c("OR", "RR", "PR", "IRR", "HR")) out$estimate[out$reference] <- 1
    usable <- cn[is.finite(b[cn])]
    k <- min(length(usable), length(nonref))
    if (k) {
      for (j in seq_len(k)) {
        nm <- usable[j]
        beta <- unname(b[nm])
        se <- sqrt(unname(V[nm, nm]))
        if (!is.finite(se) || se <= 0) next
        z <- beta / se
        p <- if (inherits(fit, "lm")) 2 * stats::pt(abs(z), df = stats::df.residual(fit), lower.tail = FALSE) else 2 * stats::pnorm(abs(z), lower.tail = FALSE)
        lo <- beta - crit * se
        hi <- beta + crit * se
        row <- which(out$level == nonref[j])[1L]
        if (identical(effect, "Beta")) {
          out$estimate[row] <- beta
          out$lower[row] <- lo
          out$upper[row] <- hi
        } else {
          out$estimate[row] <- exp(beta)
          out$lower[row] <- exp(lo)
          out$upper[row] <- exp(hi)
        }
        out$p[row] <- p
      }
    }
    return(out)
  }

  if (!length(cn)) return(NULL)
  nm <- cn[1L]
  beta <- unname(b[nm])
  se <- sqrt(unname(V[nm, nm]))
  if (!is.finite(beta) || !is.finite(se) || se <= 0) return(NULL)
  mult <- if (is.finite(per) && per > 0) per else 1
  z <- beta / se
  p <- if (inherits(fit, "lm")) 2 * stats::pt(abs(z), df = stats::df.residual(fit), lower.tail = FALSE) else 2 * stats::pnorm(abs(z), lower.tail = FALSE)
  lo <- beta - crit * se
  hi <- beta + crit * se
  if (identical(effect, "Beta")) {
    est <- beta * mult; lower <- lo * mult; upper <- hi * mult
  } else {
    est <- exp(beta * mult); lower <- exp(lo * mult); upper <- exp(hi * mult)
  }
  data.frame(variable = variable, level = "", reference = FALSE,
             model = model_name, estimate = est, lower = lower, upper = upper,
             p = p, global_p = gp, n = n, events = events,
             stringsAsFactors = FALSE)
}

.r4vn_tf_effect_type <- function(data, outcome_name, time_name,
                                 or = FALSE, rr = FALSE, pr = FALSE, irr = FALSE,
                                 estimate = c("auto", "beta", "or", "rr", "pr", "irr", "hr")) {
  estimate <- match.arg(tolower(estimate[1L]), c("auto", "beta", "or", "rr", "pr", "irr", "hr"))
  flags <- c(or = isTRUE(or), rr = isTRUE(rr), pr = isTRUE(pr), irr = isTRUE(irr))
  if (sum(flags) > 1L) stop("Choose only one of `or`, `rr`, `pr`, or `irr`.", call. = FALSE)
  if (!is.null(time_name)) {
    if (estimate != "auto" && estimate != "hr") stop("When `time` is supplied, `estimate` must be 'auto' or 'hr'.", call. = FALSE)
    if (any(flags)) stop("Do not use `or`, `rr`, `pr`, or `irr` with a survival outcome.", call. = FALSE)
    return("HR")
  }
  if (estimate != "auto") return(switch(estimate, beta = "Beta", or = "OR", rr = "RR", pr = "PR", irr = "IRR", hr = "HR"))
  if (any(flags)) return(unname(c(OR = "OR", RR = "RR", PR = "PR", IRR = "IRR")[toupper(names(flags)[which(flags)])][1L]))
  y <- data[[outcome_name]]
  if (length(.r4vn_tf_levels(y)) == 2L) return("OR")
  if (is.numeric(y)) return("Beta")
  stop("The outcome is neither binary nor numeric. Specify a supported `estimate` or recode the outcome.", call. = FALSE)
}

.r4vn_tf_text <- function(lang = c("en", "vi"), text = NULL) {
  lang <- match.arg(lang)
  out <- if (lang == "vi") list(
    characteristic = "Y\u1ebfu t\u1ed1",
    reference = "Tham chi\u1ebfu",
    crude = "\u0110\u01a1n bi\u1ebfn",
    adjusted = "Hi\u1ec7u ch\u1ec9nh",
    multi = "\u0110a bi\u1ebfn",
    p = "p",
    n = "n",
    events = "Bi\u1ebfn c\u1ed1",
    arrow_note = "M\u0169i t\u00ean cho bi\u1ebft KTC v\u01b0\u1ee3t ra ngo\u00e0i gi\u1edbi h\u1ea1n tr\u1ee5c.",
    beta = "Beta (KTC 95%)",
    OR = "OR (KTC 95%)",
    RR = "RR (KTC 95%)",
    PR = "PR (KTC 95%)",
    IRR = "IRR (KTC 95%)",
    HR = "HR (KTC 95%)"
  ) else list(
    characteristic = "Characteristic",
    reference = "Reference",
    crude = "Crude",
    adjusted = "Adjusted",
    multi = "Multivariable",
    p = "p",
    n = "n",
    events = "Events",
    arrow_note = "Arrows indicate confidence intervals extending beyond the plotting range.",
    beta = "Beta (95% CI)",
    OR = "OR (95% CI)",
    RR = "RR (95% CI)",
    PR = "PR (95% CI)",
    IRR = "IRR (95% CI)",
    HR = "HR (95% CI)"
  )
  if (!is.null(text)) {
    if (!is.list(text)) stop("`text` must be a named list.", call. = FALSE)
    for (nm in names(text)) out[[nm]] <- as.character(text[[nm]])[1L]
  }
  out
}

.r4vn_tf_rows <- function(data, focal_spec, estimates, labels = NULL,
                          level_labels = NULL, per = NULL, per_labels = NULL,
                          lang = "en", reference = TRUE) {
  rows <- list()
  for (i in seq_len(nrow(focal_spec))) {
    v <- focal_spec$variable[i]
    vl <- .r4vn_tf_var_label(data, v, labels)
    if (identical(focal_spec$type[i], "categorical")) {
      lev <- .r4vn_tf_levels(data[[v]])
      rows[[length(rows) + 1L]] <- data.frame(variable = v, level = NA_character_,
        row_type = "header", label = vl, stringsAsFactors = FALSE)
      ref0 <- .r4vn_tf_ref(data[[v]], focal_spec[i, , drop = FALSE])
      for (z in lev) {
        if (!isTRUE(reference) && identical(as.character(z), as.character(ref0))) next
        rows[[length(rows) + 1L]] <- data.frame(variable = v, level = as.character(z),
          row_type = "level", label = .r4vn_tf_level_label(v, as.character(z), level_labels), stringsAsFactors = FALSE)
      }
    } else {
      pv <- .r4vn_tf_per(v, per)
      pl <- .r4vn_tf_per_label(v, pv, per_labels, lang)
      if (!is.null(pl) && nzchar(pl)) vl <- paste0(vl, " (", pl, ")")
      rows[[length(rows) + 1L]] <- data.frame(variable = v, level = "",
        row_type = "continuous", label = vl, stringsAsFactors = FALSE)
    }
  }
  out <- do.call(rbind, rows)
  rownames(out) <- NULL
  out
}

.r4vn_tf_build_raw <- function(data, outcome_name, time_name, focal_spec,
                               adjusted, multi, effect, event, failure, ci,
                               crude, sample, per) {
  adjusted_spec <- NULL
  adjusted_all <- isTRUE(adjusted)
  if (!isFALSE(adjusted) && !is.null(adjusted) && !isTRUE(adjusted)) adjusted_spec <- .r4vn_tf_spec(adjusted, data, "adjusted")
  multi_spec <- NULL
  if (!isFALSE(multi) && !is.null(multi) && !isTRUE(multi)) multi_spec <- .r4vn_tf_spec(multi, data, "multi")
  if (isTRUE(multi)) multi_spec <- focal_spec

  n_model_groups <- sum(c(isTRUE(crude), !isFALSE(adjusted) && !is.null(adjusted), !isFALSE(multi) && !is.null(multi)))
  sample <- match.arg(sample, c("auto", "common", "model"))
  common <- identical(sample, "common") || (identical(sample, "auto") && n_model_groups > 1L)

  union_spec <- focal_spec
  if (adjusted_all) union_spec <- .r4vn_tf_merge_spec(union_spec, focal_spec)
  else union_spec <- .r4vn_tf_merge_spec(union_spec, adjusted_spec)
  union_spec <- .r4vn_tf_merge_spec(union_spec, multi_spec)
  analysis_data <- data
  if (common) {
    needed <- unique(c(outcome_name, time_name, if (identical(effect, "HR")) outcome_name else NULL,
                       union_spec$variable))
    needed <- needed[!is.na(needed) & nzchar(needed)]
    keep <- stats::complete.cases(data[, needed, drop = FALSE])
    analysis_data <- data[keep, , drop = FALSE]
    if (!nrow(analysis_data)) stop("`sample = 'common'` leaves no complete observations.", call. = FALSE)
  }

  estimates <- list(); models <- list(); model_keys <- character()
  status_name <- if (identical(effect, "HR")) outcome_name else NULL

  if (isTRUE(crude)) {
    model_keys <- c(model_keys, "Crude")
    models$Crude <- list()
    for (i in seq_len(nrow(focal_spec))) {
      fs <- focal_spec[i, , drop = FALSE]
      d <- .r4vn_tf_prepare(analysis_data, outcome_name, time_name, status_name, fs, effect, event, failure)
      m <- .r4vn_tf_fit(d, fs, effect, ci)
      z <- .r4vn_tf_one_effect(m, fs$variable, fs, effect, .r4vn_tf_per(fs$variable, per), ci, "Crude")
      if (!is.null(z)) estimates[[length(estimates) + 1L]] <- z
      models$Crude[[fs$variable]] <- m$fit
    }
  }

  if (!isFALSE(adjusted) && !is.null(adjusted)) {
    model_keys <- c(model_keys, "Adjusted")
    models$Adjusted <- list()
    for (i in seq_len(nrow(focal_spec))) {
      fs <- focal_spec[i, , drop = FALSE]
      adj <- if (adjusted_all) focal_spec[focal_spec$variable != fs$variable, , drop = FALSE] else adjusted_spec
      if (!is.null(adj) && nrow(adj)) adj <- adj[adj$variable != fs$variable, , drop = FALSE]
      ms <- .r4vn_tf_merge_spec(fs, adj)
      d <- .r4vn_tf_prepare(analysis_data, outcome_name, time_name, status_name, ms, effect, event, failure)
      m <- .r4vn_tf_fit(d, ms, effect, ci)
      z <- .r4vn_tf_one_effect(m, fs$variable, fs, effect, .r4vn_tf_per(fs$variable, per), ci, "Adjusted")
      if (!is.null(z)) estimates[[length(estimates) + 1L]] <- z
      models$Adjusted[[fs$variable]] <- m$fit
    }
  }

  if (!isFALSE(multi) && !is.null(multi)) {
    model_keys <- c(model_keys, "Multivariable")
    ms <- multi_spec
    d <- .r4vn_tf_prepare(analysis_data, outcome_name, time_name, status_name, ms, effect, event, failure)
    m <- .r4vn_tf_fit(d, ms, effect, ci)
    models$Multivariable <- m$fit
    for (i in seq_len(nrow(focal_spec))) {
      fs <- focal_spec[i, , drop = FALSE]
      if (!fs$variable %in% ms$variable) next
      fit_spec <- ms[match(fs$variable, ms$variable), , drop = FALSE]
      z <- .r4vn_tf_one_effect(m, fs$variable, fit_spec, effect, .r4vn_tf_per(fs$variable, per), ci, "Multivariable")
      if (!is.null(z)) estimates[[length(estimates) + 1L]] <- z
    }
  }

  if (!length(estimates)) stop("No estimable effects were produced.", call. = FALSE)
  estimates <- do.call(rbind, estimates)
  rownames(estimates) <- NULL
  list(estimates = estimates, models = models, model_keys = unique(model_keys),
       common_sample = common, analysis_n = nrow(analysis_data),
       adjusted_spec = adjusted_spec, adjusted_all = adjusted_all, multi_spec = multi_spec)
}

.r4vn_tf_fit_spec <- function(fit) {
  mf <- tryCatch(stats::model.frame(fit), error = function(e) NULL)
  tt <- attr(stats::terms(fit), "term.labels")
  tt <- gsub("`", "", tt, fixed = TRUE)
  if (!length(tt)) return(NULL)
  lapply_out <- lapply(tt, function(v) {
    x <- if (!is.null(mf) && v %in% names(mf)) mf[[v]] else NULL
    categorical <- !is.null(x) && (is.factor(x) || is.character(x) || is.logical(x))
    data.frame(variable = v, type = if (categorical) "categorical" else "mean",
               specification = v, reference_index = if (categorical) 1L else NA_integer_, stringsAsFactors = FALSE)
  })
  do.call(rbind, lapply_out)
}

.r4vn_tf_from_model <- function(fit, model_name = "Model", effect = NULL, vcov_override = NULL, ci = .95) {
  if (is.null(effect)) {
    if (inherits(fit, "coxph")) effect <- "HR"
    else if (inherits(fit, "lm") && !inherits(fit, "glm")) effect <- "Beta"
    else if (inherits(fit, "glm") && identical(fit$family$family, "binomial")) effect <- "OR"
    else if (inherits(fit, "glm") && identical(fit$family$family, "poisson")) effect <- "IRR"
    else stop("Could not infer the effect type from this model.", call. = FALSE)
  }
  spec <- .r4vn_tf_fit_spec(fit)
  if (is.null(spec) || !nrow(spec)) stop("The model has no predictor terms to display.", call. = FALSE)
  d <- tryCatch(stats::model.frame(fit), error = function(e) NULL)
  if (is.null(d)) stop("The fitted model does not retain a usable model frame.", call. = FALSE)
  V <- if (!is.null(vcov_override)) vcov_override else if (effect %in% c("RR", "PR")) .r4vn_tf_robust_poisson(fit) else stats::vcov(fit)
  model <- list(fit = fit, vcov = V, data = d, ci = ci)
  est <- list()
  for (i in seq_len(nrow(spec))) {
    v <- spec$variable[i]
    z <- .r4vn_tf_one_effect(model, v, spec[i, , drop = FALSE], effect, 1, ci, model_name)
    if (!is.null(z)) est[[length(est) + 1L]] <- z
  }
  list(estimates = do.call(rbind, est), spec = spec, effect = effect, models = setNames(list(fit), model_name), model_keys = model_name, data = d)
}

.r4vn_tf_rows_from_estimates <- function(estimates, labels = NULL, level_labels = NULL, reference = TRUE) {
  vars <- unique(estimates$variable)
  out <- list()
  for (v in vars) {
    z <- estimates[estimates$variable == v, , drop = FALSE]
    levels0 <- unique(z$level[!is.na(z$level) & nzchar(z$level)])
    has_ref <- any(z$reference %in% TRUE, na.rm = TRUE)
    vl <- if (!is.null(labels) && !is.null(names(labels)) && v %in% names(labels)) as.character(labels[[v]])[1L] else v
    if (length(levels0) > 1L || has_ref) {
      out[[length(out) + 1L]] <- data.frame(variable = v, level = NA_character_, row_type = "header", label = vl, stringsAsFactors = FALSE)
      for (lv in levels0) {
        is_ref <- any(z$level == lv & z$reference %in% TRUE, na.rm = TRUE)
        if (!isTRUE(reference) && is_ref) next
        out[[length(out) + 1L]] <- data.frame(variable = v, level = lv, row_type = "level", label = .r4vn_tf_level_label(v, lv, level_labels), stringsAsFactors = FALSE)
      }
    } else {
      out[[length(out) + 1L]] <- data.frame(variable = v, level = if (length(levels0)) levels0[1L] else "", row_type = "continuous", label = vl, stringsAsFactors = FALSE)
    }
  }
  do.call(rbind, out)
}

.r4vn_tf_object <- function(x, select = NULL, ci = .95) {
  if (inherits(x, "r4vn_surv")) {
    tabs <- x$cox
    if (is.null(tabs)) stop("This `r4vn_surv` object has no Cox regression results.", call. = FALSE)
    available <- c("crude", "adjusted", "multi")
    available <- available[vapply(available, function(k) !is.null(tabs[[k]]) && nrow(tabs[[k]]) > 0L, logical(1))]
    if (!length(available)) stop("This `r4vn_surv` object has no Cox estimates to plot.", call. = FALSE)
    if (is.null(select)) select <- available
    select <- intersect(tolower(select), available)
    if (!length(select)) stop("`select` did not match available Cox components.", call. = FALSE)
    nm_map <- c(crude = "Crude", adjusted = "Adjusted", multi = "Multivariable")
    est <- list()
    for (k in select) {
      z <- tabs[[k]]
      need <- c("variable", "level", "estimate", "lower", "upper", "p")
      if (!all(need %in% names(z))) stop("The Cox result table does not contain the fields required by `tabforest()`.", call. = FALSE)
      ref <- if ("reference" %in% names(z)) as.logical(z$reference) else is.na(z$estimate)
      est[[length(est) + 1L]] <- data.frame(variable = as.character(z$variable), level = as.character(z$level),
        reference = ref, model = unname(nm_map[k]), estimate = as.numeric(z$estimate), lower = as.numeric(z$lower), upper = as.numeric(z$upper),
        p = as.numeric(z$p), global_p = if ("global_p" %in% names(z)) as.numeric(z$global_p) else NA_real_,
        n = if ("n" %in% names(z)) as.numeric(z$n) else NA_real_, events = if ("events" %in% names(z)) as.numeric(z$events) else NA_real_, stringsAsFactors = FALSE)
    }
    return(list(estimates = do.call(rbind, est), effect = "HR", models = tabs, model_keys = unname(nm_map[select]), data = NULL, spec = NULL))
  }

  if (inherits(x, "r4vn_tabmulti")) {
    fits <- x$models
    if (is.null(fits) || !length(fits)) stop("This `r4vn_tabmulti` object has no fitted models.", call. = FALSE)
    available <- names(fits)
    if (is.null(select)) select <- if ("full" %in% available) "full" else available[1L]
    select <- intersect(select, available)
    if (!length(select)) stop(sprintf("Available `tabmulti()` models are: %s.", paste(available, collapse = ", ")), call. = FALSE)
    effect <- x$effect
    if (is.null(effect) || !nzchar(as.character(effect)[1L])) {
      fit0 <- fits[[select[1L]]]
      effect <- if (inherits(fit0, "glm") && identical(fit0$family$family, "binomial")) "OR" else if (inherits(fit0, "glm") && identical(fit0$family$family, "poisson")) "RR" else "Beta"
    }
    effect <- toupper(as.character(effect)[1L])
    if (effect %in% c("BETA", "COEFFICIENT")) effect <- "Beta"
    spec <- x$metadata
    if (is.null(spec) || !nrow(spec)) spec <- .r4vn_tf_fit_spec(fits[[select[1L]]])
    est <- list(); model_list <- list()
    for (k in select) {
      fit <- fits[[k]]
      model_list[[k]] <- fit
      V <- if (effect %in% c("RR", "PR")) .r4vn_tf_robust_poisson(fit) else stats::vcov(fit)
      model <- list(fit = fit, vcov = V, data = stats::model.frame(fit), ci = ci)
      selected_vars <- if (!is.null(x$selected[[k]])) x$selected[[k]] else spec$variable
      for (i in seq_len(nrow(spec))) {
        if (!spec$variable[i] %in% selected_vars) next
        z <- .r4vn_tf_one_effect(model, spec$variable[i], spec[i, , drop = FALSE], effect, 1, ci, k)
        if (!is.null(z)) est[[length(est) + 1L]] <- z
      }
    }
    return(list(estimates = do.call(rbind, est), effect = effect, models = model_list, model_keys = select, data = NULL, spec = spec))
  }

  if (inherits(x, c("r4vn_stat", "r4vn_result")) && !is.null(x$raw$model)) {
    fit <- x$raw$model
    V <- if (!is.null(x$raw$vcov)) x$raw$vcov else NULL
    effect <- NULL
    ttl <- if (!is.null(x$title)) tolower(as.character(x$title)[1L]) else ""
    if (inherits(fit, "glm") && identical(fit$family$family, "binomial")) effect <- "OR"
    else if (inherits(fit, "glm") && identical(fit$family$family, "poisson")) effect <- "IRR"
    else if (inherits(fit, "lm") && !inherits(fit, "glm")) effect <- "Beta"
    else if (inherits(fit, "coxph")) effect <- "HR"
    if (grepl("logistic", ttl, fixed = TRUE)) effect <- "OR"
    if (grepl("poisson", ttl, fixed = TRUE)) effect <- "IRR"
    if (grepl("linear regression", ttl, fixed = TRUE)) effect <- "Beta"
    return(.r4vn_tf_from_model(fit, "Model", effect, V, ci))
  }

  if (inherits(x, c("lm", "glm", "coxph"))) return(.r4vn_tf_from_model(x, "Model", NULL, NULL, ci))
  stop("Unsupported object. `tabforest()` currently accepts raw data calls, `r4vn_surv`, `r4vn_tabmulti`, `r4vn_result`, `lm`, `glm`, and `coxph` objects.", call. = FALSE)
}

.r4vn_tf_fmt_num <- function(x, digits = 2) {
  if (!length(x) || is.na(x) || !is.finite(x)) return("")
  formatC(x, format = "f", digits = digits)
}

.r4vn_tf_fmt_p <- function(x, digits = 3) {
  if (!length(x) || is.na(x) || !is.finite(x)) return("")
  lim <- 10^(-digits)
  if (x < lim) paste0("<", formatC(lim, format = "f", digits = digits)) else formatC(x, format = "f", digits = digits)
}


.r4vn_tf_text_full <- function(lang = c("en", "vi"), text = NULL) {
  lang <- match.arg(lang)
  tx <- .r4vn_tf_text(lang, text)
  extra <- if (lang == "vi") list(
    model = "M\u00f4 h\u00ecnh",
    outcome = "K\u1ebft c\u1ee5c",
    subgroup = "Ph\u00e2n nh\u00f3m",
    interaction_p = "p t\u01b0\u01a1ng t\u00e1c",
    overall = "Chung",
    effect = "\u01af\u1edbc t\u00ednh"
  ) else list(
    model = "Model",
    outcome = "Outcome",
    subgroup = "Subgroup",
    interaction_p = "P for interaction",
    overall = "Overall",
    effect = "Estimate"
  )
  for (nm in names(extra)) if (is.null(tx[[nm]])) tx[[nm]] <- extra[[nm]]
  tx
}

.r4vn_tf_publication_data <- function(rows, estimates, model_keys, display_models,
                                      effect_title, tx, effect_digit = 2,
                                      p_digit = 3, pvalue = TRUE,
                                      global_p = FALSE, show_n = FALSE,
                                      show_events = FALSE) {
  out <- data.frame(.r4vn_row_id = seq_len(nrow(rows)), stringsAsFactors = FALSE)
  out[[tx$characteristic]] <- as.character(rows$label)

  estimate_text <- function(z, reference = FALSE) {
    if (reference) return(tx$reference)
    if (!nrow(z) || !is.finite(z$estimate[1L])) return("")
    a <- paste0(.r4vn_tf_fmt_num(z$estimate[1L], effect_digit), " (",
                .r4vn_tf_fmt_num(z$lower[1L], effect_digit), "\u2013",
                .r4vn_tf_fmt_num(z$upper[1L], effect_digit), ")")
    extra <- character()
    if (isTRUE(show_n) && is.finite(z$n[1L])) extra <- c(extra, paste0(tx$n, "=", as.integer(z$n[1L])))
    if (isTRUE(show_events) && is.finite(z$events[1L])) extra <- c(extra, paste0(tx$events, "=", as.integer(z$events[1L])))
    if (length(extra)) a <- paste0(a, "; ", paste(extra, collapse = ", "))
    a
  }

  for (j in seq_along(model_keys)) {
    key <- model_keys[j]
    model_label <- as.character(display_models[[key]])
    est_col <- paste(model_label, effect_title)
    p_col <- paste(model_label, tx$p)
    est_values <- character(nrow(rows))
    p_values <- character(nrow(rows))

    for (i in seq_len(nrow(rows))) {
      r <- rows[i, , drop = FALSE]
      if (identical(r$row_type, "header")) {
        z <- estimates[estimates$variable == r$variable & estimates$model == key, , drop = FALSE]
        if (isTRUE(global_p) && nrow(z)) {
          gp <- z$global_p[is.finite(z$global_p)]
          if (length(gp)) p_values[i] <- .r4vn_tf_fmt_p(gp[1L], p_digit)
        }
      } else {
        if (identical(r$row_type, "level")) {
          z <- estimates[estimates$variable == r$variable & estimates$level == r$level & estimates$model == key, , drop = FALSE]
        } else {
          z <- estimates[estimates$variable == r$variable & (is.na(estimates$level) | estimates$level == "") & estimates$model == key, , drop = FALSE]
        }
        if (nrow(z)) {
          ref <- isTRUE(z$reference[1L])
          est_values[i] <- estimate_text(z, ref)
          if (isTRUE(pvalue) && !ref) p_values[i] <- .r4vn_tf_fmt_p(z$p[1L], p_digit)
        }
      }
    }

    out[[est_col]] <- est_values
    if (isTRUE(pvalue) || isTRUE(global_p)) out[[p_col]] <- p_values
  }

  out$.r4vn_row_id <- NULL
  names(out) <- make.unique(names(out), sep = "_")
  out
}

.r4vn_tf_resolve_named <- function(x, keys, default) {
  if (!length(keys)) return(x)
  if (length(default) == 1L) default <- rep(default, length(keys))
  default <- rep(default, length.out = length(keys))
  names(default) <- keys
  if (is.null(x)) return(default)
  if (!is.null(names(x))) {
    out <- default
    for (k in keys) if (k %in% names(x)) out[[k]] <- x[[k]]
    return(out)
  }
  out <- rep(x, length.out = length(keys))
  names(out) <- keys
  out
}

.r4vn_tf_wrap <- function(x, width) {
  if (!is.finite(width)) return(as.character(x))
  z <- strwrap(as.character(x), width = max(8L, as.integer(width)))
  if (!length(z)) "" else paste(z, collapse = "\n")
}

.r4vn_tf_expand_rows <- function(rows, model_keys, display_models,
                                 row_layout = c("auto", "modelrows", "compact"),
                                 show_model_label = TRUE) {
  row_layout <- match.arg(row_layout)
  if (identical(row_layout, "auto")) row_layout <- if (length(model_keys) > 1L) "modelrows" else "compact"
  out <- rows
  out$.display_model <- NA_character_
  out$.display_model_label <- ""
  out$.item_id <- seq_len(nrow(out))
  out$.row_group <- if (nrow(out)) cumsum(c(TRUE, out$variable[-1L] != out$variable[-nrow(out)])) else integer()
  if (identical(row_layout, "compact")) return(out)

  ans <- list()
  item_id <- 0L
  group_id <- 0L
  prev_variable <- NULL
  for (i in seq_len(nrow(rows))) {
    r <- rows[i, , drop = FALSE]
    item_id <- item_id + 1L
    if (is.null(prev_variable) || !identical(as.character(r$variable[1L]), prev_variable)) group_id <- group_id + 1L
    prev_variable <- as.character(r$variable[1L])
    if (identical(r$row_type, "header")) {
      r$.display_model <- NA_character_
      r$.display_model_label <- ""
      r$.item_id <- item_id
      r$.row_group <- group_id
      ans[[length(ans) + 1L]] <- r
      next
    }
    for (k in model_keys) {
      z <- r
      z$.display_model <- k
      z$.display_model_label <- if (isTRUE(show_model_label)) as.character(display_models[[k]]) else ""
      z$.item_id <- item_id
      z$.row_group <- group_id
      ans[[length(ans) + 1L]] <- z
    }
  }
  out <- do.call(rbind, ans)
  rownames(out) <- NULL
  out
}

.r4vn_tf_row_positions <- function(rows_plot, row_spacing = 1,
                                   model_row_gap = 0.55,
                                   group_gap = 0.25) {
  n <- nrow(rows_plot)
  if (!n) return(numeric())
  rs <- suppressWarnings(as.numeric(row_spacing)[1L]); if (!is.finite(rs) || rs <= 0) rs <- 1
  mg <- suppressWarnings(as.numeric(model_row_gap)[1L]); if (!is.finite(mg) || mg <= 0) mg <- max(.35, .55 * rs)
  gg <- suppressWarnings(as.numeric(group_gap)[1L]); if (!is.finite(gg) || gg < 0) gg <- .25 * rs

  pos <- numeric(n)
  if (n > 1L) {
    for (i in 2:n) {
      prev <- rows_plot[i - 1L, , drop = FALSE]
      cur <- rows_plot[i, , drop = FALSE]
      same_item <- isTRUE(prev$.item_id == cur$.item_id)
      gap <- if (same_item) mg else rs
      if (!same_item && !isTRUE(prev$.row_group == cur$.row_group)) gap <- gap + gg
      pos[i] <- pos[i - 1L] + gap
    }
  }
  max(pos) - pos + 1.4
}

.r4vn_tf_draw_zebra <- function(rows_plot, row_y,
                                zebra_fill = c("white", "gray94"),
                                zebra_by = c("variable", "header"),
                                xleft = 0.005, xright = 0.995) {
  zebra_by <- match.arg(zebra_by)
  if (length(zebra_fill) < 2L) zebra_fill <- rep(zebra_fill, 2L)
  group_id <- if (identical(zebra_by, "header")) rows_plot$.row_group else rows_plot$.row_group
  ug <- unique(group_id)
  ug <- ug[is.finite(ug)]
  if (!length(ug)) return(invisible(NULL))
  diffs <- diff(sort(unique(row_y)))
  pad <- if (length(diffs)) min(diffs) * .45 else .4
  for (j in seq_along(ug)) {
    idx <- which(group_id == ug[j])
    if (!length(idx)) next
    graphics::rect(xleft, min(row_y[idx]) - pad, xright, max(row_y[idx]) + pad,
                   col = zebra_fill[(j - 1L) %% length(zebra_fill) + 1L], border = NA)
  }
  invisible(NULL)
}

.r4vn_tf_numeric_text <- function(z, ref, tx, effect_digit = 2, p_digit = 3,
                                  pvalue = TRUE, show_n = FALSE,
                                  show_events = FALSE, p_layout = "inline") {
  if (ref) return(list(effect = tx$reference, p = ""))
  if (!nrow(z) || !is.finite(z$estimate[1L])) return(list(effect = "", p = ""))
  txt <- paste0(.r4vn_tf_fmt_num(z$estimate[1L], effect_digit), " (",
                .r4vn_tf_fmt_num(z$lower[1L], effect_digit), "\u2013",
                .r4vn_tf_fmt_num(z$upper[1L], effect_digit), ")")
  extra <- character()
  if (isTRUE(show_n) && is.finite(z$n[1L])) extra <- c(extra, paste0(tx$n, "=", as.integer(z$n[1L])))
  if (isTRUE(show_events) && is.finite(z$events[1L])) extra <- c(extra, paste0(tx$events, "=", as.integer(z$events[1L])))
  if (length(extra)) txt <- paste0(txt, "; ", paste(extra, collapse = ", "))
  pp <- ""
  if (isTRUE(pvalue)) pp <- .r4vn_tf_fmt_p(z$p[1L], p_digit)
  if (isTRUE(pvalue) && nzchar(pp) && identical(p_layout, "inline")) txt <- paste0(txt, "; ", tx$p, "=", pp)
  list(effect = txt, p = pp)
}

.r4vn_tf_axis_range <- function(est, null, xmin = NULL, xmax = NULL,
                                ticks = NULL, use_log = FALSE, ratio = FALSE) {
  vals <- c(est$lower, est$upper, est$estimate)
  vals <- vals[is.finite(vals)]
  if (use_log) vals <- vals[vals > 0]
  if (!length(vals)) stop("There are no finite estimates to plot.", call. = FALSE)

  if (ratio && !is.null(xmax) && is.null(xmin) && length(xmax) == 1L && is.finite(xmax) && xmax > 1) xmin <- 1 / xmax
  trans <- if (use_log) log else identity
  inv <- if (use_log) exp else identity

  if (is.null(xmin) || is.null(xmax)) {
    tv <- trans(vals)
    null_t <- trans(null)
    span <- range(c(tv, null_t), finite = TRUE)
    d <- diff(span)
    pad <- max(d * .10, if (use_log) .15 else .10)
    if (!is.finite(pad) || pad <= 0) pad <- if (use_log) .15 else .10
    if (is.null(xmin)) xmin <- inv(span[1L] - pad)
    if (is.null(xmax)) xmax <- inv(span[2L] + pad)
  }
  xmin <- as.numeric(xmin)[1L]; xmax <- as.numeric(xmax)[1L]
  if (!is.finite(xmin) || !is.finite(xmax) || xmin >= xmax) stop("`xmin` and `xmax` must define an increasing finite range.", call. = FALSE)
  if (use_log && xmin <= 0) stop("A logarithmic forest axis requires `xmin > 0`.", call. = FALSE)

  tmin <- trans(xmin); tmax <- trans(xmax)
  if (is.null(ticks)) {
    if (use_log && ratio) {
      candidate <- c(.01, .02, .05, .1, .2, .25, .5, 1, 2, 4, 5, 10, 20, 50, 100)
      ticks <- candidate[candidate >= xmin & candidate <= xmax]
      if (length(ticks) > 7L) {
        keep <- unique(round(seq(1, length(ticks), length.out = 7L)))
        ticks <- ticks[keep]
        if (xmin <= 1 && xmax >= 1 && !1 %in% ticks) ticks <- sort(unique(c(ticks, 1)))
      }
      if (length(ticks) < 2L) {
        ticks <- inv(pretty(c(tmin, tmax), n = 5))
      }
    } else if (use_log) {
      ticks <- inv(pretty(c(tmin, tmax), n = 5))
    } else {
      ticks <- pretty(c(xmin, xmax), n = 5)
    }
  }
  ticks <- as.numeric(ticks)
  ticks <- ticks[is.finite(ticks) & ticks >= xmin & ticks <= xmax & (!use_log | ticks > 0)]
  list(xmin = xmin, xmax = xmax, ticks = ticks, trans = trans, inv = inv,
       tmin = tmin, tmax = tmax)
}

.r4vn_tf_device <- function(file, width, height, dpi) {
  ext <- tolower(tools::file_ext(file))
  if (ext == "pdf") {
    if (isTRUE(capabilities("cairo"))) {
      grDevices::cairo_pdf(file, width = width, height = height, onefile = TRUE)
    } else {
      grDevices::pdf(file, width = width, height = height, onefile = TRUE)
    }
  }
  else if (ext == "png") grDevices::png(file, width = width, height = height, units = "in", res = dpi)
  else if (ext == "svg") grDevices::svg(file, width = width, height = height)
  else if (ext %in% c("jpg", "jpeg")) grDevices::jpeg(file, width = width, height = height, units = "in", res = dpi, quality = 95)
  else if (ext %in% c("tif", "tiff")) grDevices::tiff(file, width = width, height = height, units = "in", res = dpi, compression = "lzw")
  else stop("Unsupported graphics extension. Use pdf, png, svg, jpg/jpeg, or tiff.", call. = FALSE)
}

.r4vn_tf_panel_value <- function(x, panel_name, panel_index, default = NULL) {
  if (is.null(x)) return(default)
  if (is.list(x)) {
    if (!is.null(names(x)) && panel_name %in% names(x)) return(x[[panel_name]])
    if (length(x) >= panel_index) return(x[[panel_index]])
    return(default)
  }
  if (!is.null(names(x)) && panel_name %in% names(x)) return(x[[panel_name]])
  if (length(x) == 1L) return(x[[1L]])
  if (length(x) >= panel_index) return(x[[panel_index]])
  default
}

.r4vn_tf_outcome_specs <- function(outcomes, data) {
  if (is.null(outcomes)) return(NULL)
  if (is.character(outcomes)) {
    vals <- as.character(outcomes)
    labs <- names(outcomes)
    if (is.null(labs) || any(!nzchar(labs))) labs <- vals
    out <- lapply(seq_along(vals), function(i) list(outcome = vals[i], label = labs[i]))
    names(out) <- labs
    return(out)
  }
  if (!is.list(outcomes) || !length(outcomes)) stop("`outcomes` must be a named character vector or a named list of outcome specifications.", call. = FALSE)
  labs <- names(outcomes)
  if (is.null(labs)) labs <- rep("", length(outcomes))
  out <- vector("list", length(outcomes))
  for (i in seq_along(outcomes)) {
    z <- outcomes[[i]]
    if (is.character(z) && length(z) == 1L) z <- list(outcome = z)
    if (!is.list(z) || is.null(z$outcome)) stop("Each element of `outcomes` must contain `outcome`.", call. = FALSE)
    z$outcome <- as.character(z$outcome)[1L]
    if (!z$outcome %in% names(data)) stop(sprintf("Outcome `%s` was not found in `data`.", z$outcome), call. = FALSE)
    if (!is.null(z$time)) {
      z$time <- as.character(z$time)[1L]
      if (!z$time %in% names(data)) stop(sprintf("Time variable `%s` was not found in `data`.", z$time), call. = FALSE)
    }
    lab <- if (nzchar(labs[i])) labs[i] else if (!is.null(z$label)) as.character(z$label)[1L] else z$outcome
    z$label <- lab
    out[[i]] <- z
  }
  names(out) <- vapply(out, function(z) z$label, character(1))
  out
}

.r4vn_tf_formula_interaction <- function(response, predictor, subgroup, covariates = character()) {
  p <- .r4vn_tf_escape_name(predictor)
  s <- .r4vn_tf_escape_name(subgroup)
  rhs <- paste0(p, " * ", s)
  if (length(covariates)) rhs <- paste(rhs, paste(vapply(covariates, .r4vn_tf_escape_name, character(1)), collapse = " + "), sep = " + ")
  stats::as.formula(paste(response, "~", rhs))
}

.r4vn_tf_fit_interaction <- function(d, predictor, subgroup, covariates, effect) {
  response <- if (identical(effect, "HR")) "survival::Surv(.time, .status)" else ".outcome"
  f <- .r4vn_tf_formula_interaction(response, predictor, subgroup, covariates)
  if (identical(effect, "HR")) {
    if (!requireNamespace("survival", quietly = TRUE)) stop("Cox subgroup forests require the `survival` package.", call. = FALSE)
    fit <- survival::coxph(f, data = d, x = TRUE, y = TRUE, model = TRUE, ties = "efron")
    V <- stats::vcov(fit)
  } else if (identical(effect, "Beta")) {
    fit <- stats::lm(f, data = d, x = TRUE, y = TRUE)
    V <- stats::vcov(fit)
  } else if (identical(effect, "OR")) {
    fit <- stats::glm(f, family = stats::binomial("logit"), data = d, x = TRUE, y = TRUE)
    V <- stats::vcov(fit)
  } else {
    fit <- stats::glm(f, family = stats::poisson("log"), data = d, x = TRUE, y = TRUE)
    V <- if (effect %in% c("RR", "PR")) .r4vn_tf_robust_poisson(fit) else stats::vcov(fit)
  }
  list(fit = fit, vcov = V)
}

.r4vn_tf_interaction_p <- function(fit_obj, predictor, subgroup) {
  fit <- fit_obj$fit; V <- fit_obj$vcov
  mm <- stats::model.matrix(fit)
  ass <- attr(mm, "assign")
  tl <- attr(stats::terms(fit), "term.labels")
  clean <- gsub("`", "", tl, fixed = TRUE)
  int_idx <- which(vapply(clean, function(z) {
    parts <- strsplit(z, ":", fixed = TRUE)[[1L]]
    predictor %in% parts && subgroup %in% parts
  }, logical(1)))
  if (!length(int_idx)) return(NA_real_)
  cn <- colnames(mm)[ass %in% int_idx]
  b <- stats::coef(fit)
  cn <- intersect(cn, names(b))
  cn <- cn[is.finite(b[cn])]
  if (!length(cn)) return(NA_real_)
  vv <- V[cn, cn, drop = FALSE]
  good <- is.finite(diag(vv)) & diag(vv) > 0
  cn <- cn[good]
  if (!length(cn)) return(NA_real_)
  bb <- b[cn]; vv <- V[cn, cn, drop = FALSE]
  inv <- tryCatch(solve(vv), error = function(e) tryCatch(qr.solve(vv), error = function(e2) NULL))
  if (is.null(inv)) return(NA_real_)
  q <- as.numeric(t(bb) %*% inv %*% bb)
  df <- qr(vv)$rank
  if (!is.finite(q) || df < 1L) return(NA_real_)
  stats::pchisq(q, df = df, lower.tail = FALSE)
}

.r4vn_tf_subgroup_adjust_spec <- function(data, predictor_spec, adjusted, multi) {
  if (isTRUE(adjusted)) stop("In subgroup mode, use `adjusted = vars(...)` rather than `adjusted = TRUE` so the adjustment set is explicit.", call. = FALSE)
  out <- NULL
  if (!isFALSE(adjusted) && !is.null(adjusted)) out <- .r4vn_tf_spec(adjusted, data, "adjusted")
  if (!isFALSE(multi) && !is.null(multi) && !isTRUE(multi)) out <- .r4vn_tf_spec(multi, data, "multi")
  if (!is.null(out) && nrow(out)) out <- out[out$variable != predictor_spec$variable[1L], , drop = FALSE]
  out
}

.r4vn_tf_subgroup_build <- function(data, outcome_name, time_name,
                                    predictor_spec, subgroup_spec,
                                    adjusted = FALSE, multi = FALSE,
                                    effect, event = NULL, failure = NULL,
                                    ci = .95, sample = c("auto", "common", "model"),
                                    per = NULL, labels = NULL, level_labels = NULL) {
  sample <- match.arg(sample)
  pred <- predictor_spec$variable[1L]
  if (identical(predictor_spec$type[1L], "categorical") && length(.r4vn_tf_levels(data[[pred]])) != 2L) {
    stop("Subgroup forest currently requires a continuous predictor or a two-level categorical predictor.", call. = FALSE)
  }
  for (i in seq_len(nrow(subgroup_spec))) {
    sg <- subgroup_spec$variable[i]
    if (identical(subgroup_spec$type[i], "mean")) stop(sprintf("Subgroup variable `%s` must be categorical. Create a grouped variable first.", sg), call. = FALSE)
  }
  adj_spec <- .r4vn_tf_subgroup_adjust_spec(data, predictor_spec, adjusted, multi)
  status_name <- if (identical(effect, "HR")) outcome_name else NULL

  analysis_data <- data
  common <- identical(sample, "common")
  if (common) {
    need <- unique(c(outcome_name, time_name, pred, subgroup_spec$variable,
                     if (!is.null(adj_spec)) adj_spec$variable else NULL))
    keep <- stats::complete.cases(data[, need, drop = FALSE])
    analysis_data <- data[keep, , drop = FALSE]
  }

  est_list <- list(); rows <- list(); models <- list(); interactions <- list()
  for (i in seq_len(nrow(subgroup_spec))) {
    sg <- subgroup_spec$variable[i]
    sg_label <- .r4vn_tf_var_label(data, sg, labels)
    rows[[length(rows) + 1L]] <- data.frame(variable = sg, level = NA_character_, row_type = "header", label = sg_label, stringsAsFactors = FALSE)

    cov_spec <- adj_spec
    if (!is.null(cov_spec) && nrow(cov_spec)) cov_spec <- cov_spec[!cov_spec$variable %in% c(pred, sg), , drop = FALSE]
    full_spec <- .r4vn_tf_merge_spec(predictor_spec, subgroup_spec[i, , drop = FALSE], cov_spec)
    d_int <- tryCatch(.r4vn_tf_prepare(analysis_data, outcome_name, time_name, status_name, full_spec, effect, event, failure), error = function(e) NULL)
    ip <- NA_real_; int_fit <- NULL
    if (!is.null(d_int) && nrow(d_int)) {
      int_fit <- tryCatch(.r4vn_tf_fit_interaction(d_int, pred, sg, if (is.null(cov_spec)) character() else cov_spec$variable, effect), error = function(e) NULL)
      if (!is.null(int_fit)) ip <- .r4vn_tf_interaction_p(int_fit, pred, sg)
    }
    interactions[[sg]] <- int_fit

    levs <- .r4vn_tf_levels(data[[sg]])
    for (lv in levs) {
      rows[[length(rows) + 1L]] <- data.frame(variable = sg, level = as.character(lv), row_type = "level",
                                              label = .r4vn_tf_level_label(sg, as.character(lv), level_labels), stringsAsFactors = FALSE)
      sub <- analysis_data[as.character(analysis_data[[sg]]) == as.character(lv) & !is.na(analysis_data[[sg]]), , drop = FALSE]
      ms <- .r4vn_tf_merge_spec(predictor_spec, cov_spec)
      z <- NULL; fit <- NULL
      if (nrow(sub)) {
        z <- tryCatch({
          dd <- .r4vn_tf_prepare(sub, outcome_name, time_name, status_name, ms, effect, event, failure)
          m <- .r4vn_tf_fit(dd, ms, effect, ci)
          fit <- m$fit
          e <- .r4vn_tf_one_effect(m, pred, predictor_spec, effect, .r4vn_tf_per(pred, per), ci, "Subgroup")
          if (identical(predictor_spec$type[1L], "categorical")) e <- e[!e$reference & is.finite(e$estimate), , drop = FALSE]
          else e <- e[is.finite(e$estimate), , drop = FALSE]
          if (nrow(e)) e[1L, , drop = FALSE] else NULL
        }, error = function(e) NULL)
      }
      models[[paste(sg, lv, sep = "::")]] <- fit
      if (is.null(z)) {
        z <- data.frame(variable = sg, level = as.character(lv), reference = FALSE,
                        model = "Subgroup", estimate = NA_real_, lower = NA_real_, upper = NA_real_,
                        p = NA_real_, global_p = NA_real_, n = nrow(sub), events = NA_real_, stringsAsFactors = FALSE)
      } else {
        z$variable <- sg
        z$level <- as.character(lv)
      }
      z$interaction_p <- ip
      z$subgroup_variable <- sg
      z$subgroup_level <- as.character(lv)
      est_list[[length(est_list) + 1L]] <- z
    }
  }

  estimates <- do.call(rbind, est_list)
  rows <- do.call(rbind, rows)
  rownames(estimates) <- NULL; rownames(rows) <- NULL
  list(estimates = estimates, rows = rows, models = models, interactions = interactions,
       adjusted_spec = adj_spec, common_sample = common)
}

.r4vn_tf_subgroup_publication <- function(rows, estimates, tx, effect_title,
                                          pvalue = TRUE, show_interaction_p = TRUE,
                                          effect_digit = 2, p_digit = 3,
                                          show_n = FALSE, show_events = FALSE) {
  out <- data.frame(.id = seq_len(nrow(rows)), stringsAsFactors = FALSE)
  out[[tx$subgroup]] <- rows$label
  out[[effect_title]] <- ""
  if (isTRUE(pvalue)) out[[tx$p]] <- ""
  if (isTRUE(show_interaction_p)) out[[tx$interaction_p]] <- ""
  for (i in seq_len(nrow(rows))) {
    r <- rows[i, , drop = FALSE]
    if (identical(r$row_type, "header")) {
      if (isTRUE(show_interaction_p)) {
        z <- estimates[estimates$subgroup_variable == r$variable, , drop = FALSE]
        ip <- z$interaction_p[is.finite(z$interaction_p)]
        if (length(ip)) out[[tx$interaction_p]][i] <- .r4vn_tf_fmt_p(ip[1L], p_digit)
      }
      next
    }
    z <- estimates[estimates$subgroup_variable == r$variable & estimates$subgroup_level == r$level, , drop = FALSE]
    if (!nrow(z) || !is.finite(z$estimate[1L])) next
    txt <- paste0(.r4vn_tf_fmt_num(z$estimate[1L], effect_digit), " (",
                  .r4vn_tf_fmt_num(z$lower[1L], effect_digit), "\u2013",
                  .r4vn_tf_fmt_num(z$upper[1L], effect_digit), ")")
    extra <- character()
    if (isTRUE(show_n) && is.finite(z$n[1L])) extra <- c(extra, paste0(tx$n, "=", as.integer(z$n[1L])))
    if (isTRUE(show_events) && is.finite(z$events[1L])) extra <- c(extra, paste0(tx$events, "=", as.integer(z$events[1L])))
    if (length(extra)) txt <- paste0(txt, "; ", paste(extra, collapse = ", "))
    out[[effect_title]][i] <- txt
    if (isTRUE(pvalue)) out[[tx$p]][i] <- .r4vn_tf_fmt_p(z$p[1L], p_digit)
  }
  out$.id <- NULL
  out
}


#' Flexible regression, multi-outcome, survival, and subgroup forest plots
#'
#' `tabforest()` is the common forest-plot engine for R4VN. It can (1) fit
#' regression models directly from an outcome and focal predictors, (2) reuse a
#' fitted R4VN or standard R model, (3) place several outcomes side by side using
#' the same predictor structure, and (4) create subgroup-effect forests with a
#' p-value for interaction. Numeric estimates are always stored without clipping;
#' `xmin` and `xmax` affect only the drawing.
#'
#' @param outcome Outcome variable for ordinary regression/subgroup analysis, or a
#'   supported fitted object (`r4vn_surv`, `r4vn_tabmulti`, `r4vn_stat`, `lm`,
#'   `glm`, `coxph`). For Cox regression, `outcome` is the event/status variable.
#'   May be omitted when `outcomes` is supplied.
#' @param predictors Focal predictors that should appear in the forest. Prefer
#'   `vars()` so R4VN type/reference declarations are retained, for example
#'   `vars(c.age, b2.sex, c.bmi, smoking)`.
#' @param data Data frame. If omitted, the active R4VN data frame is used.
#' @param time Follow-up time variable for Cox regression. Supplying `time`
#'   automatically selects HR unless another incompatible effect is requested.
#' @param event Modeled event level for binary OR/RR/PR outcomes.
#' @param failure Event value for Cox regression. With numeric 0/1 status, 1 is
#'   selected automatically when present.
#' @param outcomes Optional named character vector or named list for a
#'   multi-outcome forest. Character example: `c(HTN="hypertension",
#'   DEP="depression")`. A list allows outcome-specific settings, e.g.
#'   `list(HTN=list(outcome="hypertension",event="Yes"),
#'   Death=list(outcome="death",time="followup",failure=1,estimate="hr"))`.
#' @param subgroup Optional categorical variables for subgroup analysis. When
#'   supplied, use `predictor` for the main exposure whose effect is estimated
#'   within each subgroup level.
#' @param predictor Main exposure for subgroup mode. It may be continuous or a
#'   two-level categorical variable. Use `vars(b2.treatment)` when a non-default
#'   reference is required.
#' @param type Analysis mode: `"auto"`, `"regression"`, `"multioutcome"`, or
#'   `"subgroup"`. `"auto"` chooses multi-outcome when `outcomes` is non-NULL,
#'   subgroup when `subgroup` is non-NULL, otherwise ordinary regression.
#' @param crude For regression/multi-outcome mode, fit one crude model per focal
#'   predictor. Set `FALSE` when only adjusted/multivariable estimates are wanted.
#' @param adjusted Regression mode: `FALSE`, `TRUE`, or `vars(...)`. With
#'   `vars(X1,X2)`, each focal predictor gets a separate model adjusted for X1/X2.
#'   With `TRUE`, each focal predictor is adjusted for all other focal predictors.
#'   Subgroup mode requires an explicit `vars(...)` adjustment set if adjustment
#'   is desired.
#' @param multi Regression mode: `FALSE`, `TRUE`, or `vars(...)`. `TRUE` fits one
#'   joint model containing all focal predictors. `vars(A,B,C,X)` fits that exact
#'   joint model but still displays only variables listed in `predictors`.
#'   In subgroup mode, an explicit `vars(...)` may also be used as the final
#'   adjustment set; the exposure and current subgroup variable are removed from
#'   the covariate set automatically.
#' @param or,rr,pr,irr Logical shortcuts for OR, RR, PR, or IRR. Only one may be
#'   TRUE. Binary outcomes default to OR; numeric outcomes default to beta.
#' @param estimate Explicit effect type: `"auto"`, `"beta"`, `"or"`, `"rr"`,
#'   `"pr"`, `"irr"`, or `"hr"`.
#' @param ci Confidence level, default 0.95.
#' @param sample Missing-data strategy. `"common"` forces displayed regression
#'   models to use the same complete-case sample; `"model"` allows each model to
#'   use its own available cases; `"auto"` uses a common sample when several
#'   regression model groups are displayed. In subgroup mode, `"auto"` behaves
#'   like model-specific analysis so unrelated subgroup variables do not reduce
#'   one another's sample size.
#' @param per Optional multiplier for continuous effects. Example `c(age=10)`
#'   reports the ratio/HR per 10 years or beta per 10 units.
#' @param per_labels Optional display labels for `per`, e.g.
#'   `c(age="per 10 years")`.
#' @param select Model components when `outcome` is a fitted R4VN object. For
#'   `tabsurv()` this can include `"crude"`, `"adjusted"`, `"multi"`; for
#'   `tabmulti()` use stored model names such as `"full"`, `"backward"`.
#' @param xmin,xmax Forest plotting limits. These never alter stored estimates.
#'   For ratio effects, if only `xmax` is supplied and is >1, `xmin=1/xmax` is
#'   used automatically. In multi-outcome mode these may be named vectors or
#'   lists keyed by outcome-panel name.
#' @param ticks Optional axis ticks. In multi-outcome mode a named list can give
#'   different ticks to different outcome panels.
#' @param log `NULL` or logical. Ratio effects default to logarithmic axes, beta
#'   to linear. In multi-outcome mode this may be a named logical vector/list.
#' @param arrows Draw arrowheads when CIs extend beyond plotting limits. When the
#'   point estimate itself is outside the range, no false boundary point is drawn.
#' @param row_layout `"auto"`, `"modelrows"`, or `"compact"`. The default
#'   `"auto"` uses `"modelrows"` whenever more than one model group is displayed
#'   and `"compact"` when only one model is displayed. In `"modelrows"`, Crude,
#'   Adjusted, and Multivariable are separate physical rows but share ONE effect
#'   column (for example, one `OR (95% CI)` column). Thus each CI, marker, numeric
#'   estimate, and p-value is aligned with its own row. Use `"compact"` only when
#'   several model estimates are deliberately wanted on the same labelled row.
#' @param layout In compact mode, `"dodge"` or `"stack"` controls vertical
#'   offsets of multiple model markers/CI lines.
#' @param row_spacing Baseline distance between ordinary rows.
#' @param model_row_gap Distance between model rows belonging to the same
#'   variable/level when `row_layout="modelrows"`.
#' @param group_gap Extra vertical separation between variable blocks.
#' @param order Optional order of focal predictor variable names.
#' @param reference Show categorical reference rows. Default TRUE.
#' @param pvalue Show coefficient-level p-values.
#' @param global_p Show categorical-variable omnibus Wald p-values. Default
#'   FALSE because forest plots are usually cleaner without these values.
#' @param show_n,show_events Add model N and number of events after the estimate.
#' @param show_model_label In `modelrows`, print the model label (Crude, Adjusted,
#'   Multivariable) beside the corresponding row.
#' @param show_interaction_p In subgroup mode, show the p-value for interaction on
#'   the subgroup-variable header row.
#' @param p_layout Regression display style: `"inline"` appends p to the numeric
#'   effect string; `"column"` uses a separate p-value column. `modelrows` works
#'   particularly well with either style.
#' @param effect_digit,p_digit Decimal places for effects and p-values.
#' @param labels Named character vector overriding variable labels.
#' @param level_labels Named list overriding displayed categorical levels. This
#'   changes display only, not model coding/reference levels.
#' @param lang Built-in language: `"en"` or `"vi"`.
#' @param text Named list overriding individual words. Useful keys include
#'   `characteristic`, `reference`, `crude`, `adjusted`, `multi`, `p`, `n`,
#'   `events`, `subgroup`, `interaction_p`, `overall`, `arrow_note`, and effect
#'   keys `OR`, `RR`, `PR`, `IRR`, `HR`, `beta`.
#' @param model_labels Named character vector overriding model labels, e.g.
#'   `c(Crude="Unadjusted",Multivariable="Adjusted")`.
#' @param label_title,effect_title,axis_title Optional column/axis titles.
#' @param title,subtitle,caption Optional plot title, subtitle, caption.
#' @param note TRUE for an automatic clipping note, FALSE for none, or custom text.
#' @param template Visual preset: `"journal"`, `"clean"`, `"minimal"`.
#' @param grid `"major"`, `"none"`, or `"both"`.
#' @param font_family Base graphics font family.
#' @param base_size,label_cex,header_cex,axis_cex,model_cex Text-size controls.
#' @param colors Model line/marker colors. A named vector is recommended, e.g.
#'   `c(Crude="gray50",Adjusted="navy",Multivariable="firebrick")`.
#' @param fills Optional marker fill colors, useful with pch 21:25.
#' @param pch Model marker symbols. Named vectors may assign different symbols to
#'   crude and adjusted estimates.
#' @param lty Model CI line types.
#' @param point_cex Model marker sizes. May be scalar or named vector by model.
#' @param point_lwd Marker border widths. May be scalar or named vector.
#' @param ci_lwd CI line widths. May be scalar or named vector by model.
#' @param ref_lwd,ref_lty,ref_col Null-line appearance.
#' @param arrow_length Arrowhead size in inches.
#' @param zebra Draw alternating background blocks by predictor/subgroup.
#' @param zebra_fill Two or more background colors, e.g.
#'   `c("white","gray93")`.
#' @param zebra_by `"variable"` or `"header"`; both currently alternate complete
#'   variable/subgroup blocks so all levels/models in a block share a background.
#' @param label_width,forest_width,column_gap Horizontal layout controls for a
#'   single regression/subgroup forest.
#' @param panel_gap Gap between panels in multi-outcome mode.
#' @param panel_forest_ratio Fraction of each multi-outcome panel devoted to the
#'   CI forest; the remaining panel width is used for numeric estimates.
#' @param label_indent Indentation of categorical levels.
#' @param label_wrap Approximate wrapping width for long labels; Inf disables.
#' @param file Optional PDF/PNG/SVG/JPG/TIFF output file.
#' @param width,height,dpi Graphics dimensions. If height is NULL it grows with
#'   the actual number of drawn rows, so `modelrows` can produce a tall figure
#'   without compressing row spacing.
#' @param show Draw immediately. Default TRUE.
#' @param console Print the long standardized estimate table.
#'
#' @return An object of class `r4vn_tabforest`; multi-outcome and subgroup modes
#'   add subclasses `r4vn_tabforest_multi` and `r4vn_tabforest_subgroup`.
#'   `$data` is publication-ready, `$table` is the numeric long table, `$models`
#'   stores fitted models, and `plot()` can redraw without refitting.
#'
#' @details
#' ## 1. Regression model semantics
#'
#' With `predictors=vars(A,B,C)`:
#'
#' * `crude=TRUE`: Y~A, Y~B, Y~C.
#' * `adjusted=vars(X)`: Y~A+X, Y~B+X, Y~C+X.
#' * `adjusted=TRUE`: each focal variable is adjusted for the other focal vars.
#' * `multi=TRUE`: one joint model Y~A+B+C.
#' * `multi=vars(A,B,C,X)`: one joint model Y~A+B+C+X, but only A/B/C are shown.
#'
#' Therefore `adjusted` and `multi` answer different scientific questions and may
#' be requested together in the same forest.
#' When two or more model groups are displayed, `row_layout="auto"` uses separate
#' model rows and ONE shared effect column. For example, Crude and Multivariable
#' ORs are both printed under the same `OR (95% CI)` header instead of being put
#' in separate Crude-OR and Multivariable-OR columns.
#'
#' ## 2. Effect measure selected by outcome
#'
#' * numeric continuous outcome -> linear regression -> beta, null=0;
#' * binary outcome -> logistic regression -> OR, null=1;
#' * `pr=TRUE` -> robust modified Poisson -> PR, null=1;
#' * `rr=TRUE` -> robust modified Poisson -> RR, null=1;
#' * count outcome + `irr=TRUE` -> Poisson -> IRR, null=1;
#' * `time=` -> Cox proportional hazards -> HR, null=1.
#'
#' ## 3. Exact numbers when the forest is clipped
#'
#' Suppose OR=7.41 and 95% CI=1.56 to 35.20 while `xmax=10`. The printed number
#' remains `7.41 (1.56-35.20)`. Only the graphical CI is truncated at 10 and an
#' arrow is drawn. If OR itself exceeds 10, no marker is placed falsely at 10.
#'
#' ## 4. Multiple models: separate rows, one merged effect column
#'
#' `row_layout="auto"` is the default. If more than one model group is present,
#' it automatically switches to the `"modelrows"` layout. Crude, Adjusted and/or
#' Multivariable estimates are placed on separate physical rows, while the right
#' side contains only ONE shared effect column such as `OR (95% CI)`, `HR (95% CI)`,
#' or `Beta (95% CI)`. The result, CI line, marker and p-value therefore stay on
#' exactly the same row. This is the recommended publication layout when crude and
#' adjusted estimates are presented together. Increase `row_spacing`,
#' `model_row_gap`, `group_gap`, or leave `height=NULL` for a taller figure.
#'
#' Set `row_layout="compact"` only when you intentionally want several model
#' estimates on the same labelled row; compact mode retains separate numeric model
#' columns because the rows are not expanded.
#'
#' ## 5. Multi-outcome forests
#'
#' `outcomes=` creates side-by-side panels sharing predictor labels. Every panel
#' may have its own effect type, axis range and follow-up variable. This permits
#' two binary outcomes (OR panels), several continuous outcomes (beta panels), or
#' even mixed OR/HR panels in one figure. For very many panels, increase `width`.
#'
#' ## 6. Subgroup forests
#'
#' `subgroup=` estimates the effect of one main `predictor` separately within
#' each subgroup level. A full model containing predictor*subgroup is fitted for
#' the Wald interaction p-value. For RR/PR, the interaction Wald test uses the
#' same robust covariance approach as the effect model. Cox subgroup forests use
#' HR and a Cox interaction model. A subgroup variable must be categorical; make
#' clinically meaningful groups before calling `tabforest()`.
#'
#' ## 7. Styling
#'
#' `colors`, `fills`, `pch`, `lty`, `point_cex`, `point_lwd`, and `ci_lwd` accept
#' named model vectors. `zebra=TRUE` shades complete variable blocks, closely
#' matching journal forest-table layouts. `lang="vi"`, `text=`, `labels=`, and
#' `level_labels=` allow all visible wording to be translated without changing
#' the model.
#'
#' @seealso `vars`, `tab`, `tabmulti`, `tabsurv`, `tabexport`
#' @family R4VN tables
#'
#' @examples
#' data(tabforest_demo)
#' usedf(tabforest_demo, quiet = TRUE)
#'
#' # Crude odds ratios.
#' f1 <- tabforest(
#'   hypertension,
#'   predictors = vars(c.age, sex, c.bmi, smoking),
#'   event = "Yes",
#'   show = FALSE
#' )
#' f1$data
#'
#' # Crude plus one final multivariable model.
#' f2 <- tabforest(
#'   hypertension,
#'   predictors = vars(c.age, sex, c.bmi, smoking),
#'   event = "Yes",
#'   crude = TRUE, multi = TRUE,
#'   show = FALSE
#' )
#'
#' # Re-drawing is intentionally interactive so CRAN examples do not depend
#' # on the graphics device or installed fonts.
#' if (interactive()) {
#'   plot(f2, row_layout = "modelrows", zebra = TRUE)
#' }
#'
#' # Each focal predictor adjusted for the same confounders.
#' f3 <- tabforest(
#'   hypertension,
#'   predictors = vars(c.age, c.bmi, smoking),
#'   event = "Yes",
#'   adjusted = vars(sex, education),
#'   show = FALSE
#' )
#'
#' # Vietnamese display text can be prepared without drawing during checks.
#' f_vi <- tabforest(
#'   hypertension,
#'   predictors = vars(c.age, sex, c.bmi, smoking),
#'   event = "Yes",
#'   multi = TRUE,
#'   lang = "vi",
#'   labels = c(
#'     age = "Tu\u1ed5i",
#'     sex = "Gi\u1edbi t\u00ednh",
#'     bmi = "Ch\u1ec9 s\u1ed1 kh\u1ed1i c\u01a1 th\u1ec3",
#'     smoking = "H\u00fat thu\u1ed1c"
#'   ),
#'   level_labels = list(
#'     sex = c(Female = "N\u1eef", Male = "Nam"),
#'     smoking = c(No = "Kh\u00f4ng", Yes = "C\u00f3")
#'   ),
#'   text = list(reference = "Tham chi\u1ebfu"),
#'   title = "Bi\u1ec3u \u0111\u1ed3 forest",
#'   show = FALSE
#' )
#' if (interactive()) plot(f_vi)
#'
#' \donttest{
#' # Modified-Poisson prevalence ratio.
#' f_pr <- tabforest(
#'   depression,
#'   predictors = vars(c.age, sex, smoking, alcohol),
#'   event = "Yes", pr = TRUE, multi = TRUE,
#'   show = FALSE
#' )
#'
#' # Continuous outcome.
#' f_beta <- tabforest(
#'   sbp,
#'   predictors = vars(c.age, sex, c.bmi, smoking),
#'   multi = TRUE,
#'   show = FALSE
#' )
#'
#' # Cox model, only when the suggested package is available.
#' if (requireNamespace("survival", quietly = TRUE)) {
#'   f_hr <- tabforest(
#'     death, time = followup,
#'     predictors = vars(c.age, sex, treatment, c.bmi),
#'     failure = 1, crude = TRUE, multi = TRUE,
#'     show = FALSE
#'   )
#' }
#'
#' # Multi-outcome forest without drawing.
#' f_multi <- tabforest(
#'   outcomes = c(
#'     Hypertension = "hypertension",
#'     Depression = "depression"
#'   ),
#'   predictors = vars(c.age, sex, c.bmi, smoking),
#'   event = "Yes", crude = FALSE, multi = TRUE,
#'   show = FALSE
#' )
#'
#' # Subgroup forest without drawing.
#' f_sub <- tabforest(
#'   hypertension,
#'   predictor = vars(treatment),
#'   subgroup = vars(age_group, sex, obesity, diabetes, smoking),
#'   event = "Yes", type = "subgroup",
#'   adjusted = vars(c.age, c.bmi),
#'   show = FALSE
#' )
#'
#' # File output uses a temporary path and is cleaned up.
#' f_png <- tempfile(fileext = ".png")
#' tabforest(
#'   hypertension,
#'   predictors = vars(c.age, sex, c.bmi, smoking),
#'   event = "Yes", multi = TRUE,
#'   file = f_png, width = 8, height = 5, dpi = 120,
#'   show = FALSE
#' )
#' unlink(f_png)
#' }
#'
#' usedf(clear = TRUE, quiet = TRUE)
#' @export
tabforest <- function(outcome = NULL, predictors = NULL, data = NULL,
                      time = NULL, event = NULL, failure = NULL,
                      outcomes = NULL, subgroup = NULL, predictor = NULL,
                      type = c("auto", "regression", "multioutcome", "subgroup"),
                      crude = TRUE, adjusted = FALSE, multi = FALSE,
                      or = FALSE, rr = FALSE, pr = FALSE, irr = FALSE,
                      estimate = c("auto", "beta", "or", "rr", "pr", "irr", "hr"),
                      ci = 0.95, sample = c("auto", "common", "model"),
                      per = NULL, per_labels = NULL, select = NULL,
                      xmin = NULL, xmax = NULL, ticks = NULL, log = NULL,
                      arrows = TRUE,
                      row_layout = c("auto", "modelrows", "compact"),
                      layout = c("dodge", "stack"),
                      row_spacing = 1, model_row_gap = 0.55, group_gap = 0.25,
                      order = NULL, reference = TRUE,
                      pvalue = TRUE, global_p = FALSE,
                      show_n = FALSE, show_events = FALSE,
                      show_model_label = TRUE, show_interaction_p = TRUE,
                      p_layout = c("inline", "column"),
                      effect_digit = 2, p_digit = 3,
                      labels = NULL, level_labels = NULL,
                      lang = c("en", "vi"), text = NULL, model_labels = NULL,
                      label_title = NULL, effect_title = NULL, axis_title = NULL,
                      title = NULL, subtitle = NULL, caption = NULL, note = TRUE,
                      template = c("journal", "clean", "minimal"),
                      grid = c("major", "none", "both"), font_family = "",
                      base_size = 11, label_cex = 1, header_cex = 1,
                      axis_cex = 1, model_cex = 0.86,
                      colors = NULL, fills = NULL, pch = NULL, lty = NULL,
                      point_cex = 1.15, point_lwd = 1, ci_lwd = 1.2,
                      ref_lwd = 1, ref_lty = 2, ref_col = "gray45",
                      arrow_length = 0.08,
                      zebra = FALSE, zebra_fill = c("white", "gray94"),
                      zebra_by = c("variable", "header"),
                      label_width = 0.28, forest_width = 0.32,
                      column_gap = 0.012, panel_gap = 0.012,
                      panel_forest_ratio = 0.58,
                      label_indent = 0.018, label_wrap = 38,
                      file = NULL, width = 12, height = NULL, dpi = 300,
                      show = TRUE, console = FALSE) {
  call <- match.call()
  env <- parent.frame()
  type <- match.arg(type)
  lang <- match.arg(lang)
  row_layout <- match.arg(row_layout)
  layout <- match.arg(layout)
  template <- match.arg(template)
  grid <- match.arg(grid)
  zebra_by <- match.arg(zebra_by)
  p_layout <- match.arg(p_layout)
  sample <- match.arg(sample)
  if (type == "auto") type <- if (!is.null(outcomes)) "multioutcome" else if (!is.null(subgroup)) "subgroup" else "regression"
  if (!is.numeric(ci) || length(ci) != 1L || !is.finite(ci) || ci <= 0 || ci >= 1) stop("`ci` must be between 0 and 1.", call. = FALSE)
  for (nm in c("effect_digit", "p_digit")) {
    val <- get(nm)
    if (!is.numeric(val) || length(val) != 1L || is.na(val) || val < 0 || val != floor(val)) stop(sprintf("`%s` must be a non-negative integer.", nm), call. = FALSE)
  }

  common_settings <- list(
    ci=ci, xmin=xmin, xmax=xmax, ticks=ticks, log=log, arrows=arrows,
    row_layout=row_layout, layout=layout, row_spacing=row_spacing,
    model_row_gap=model_row_gap, group_gap=group_gap,
    reference=reference, pvalue=pvalue, global_p=global_p,
    show_n=show_n, show_events=show_events,
    show_model_label=show_model_label, show_interaction_p=show_interaction_p,
    p_layout=p_layout, effect_digit=effect_digit, p_digit=p_digit,
    labels=labels, level_labels=level_labels, lang=lang, text=text,
    model_labels=model_labels, label_title=label_title,
    effect_title=effect_title, axis_title=axis_title,
    title=title, subtitle=subtitle, caption=caption, note=note,
    template=template, grid=grid, font_family=font_family,
    base_size=base_size, label_cex=label_cex, header_cex=header_cex,
    axis_cex=axis_cex, model_cex=model_cex,
    colors=colors, fills=fills, pch=pch, lty=lty,
    point_cex=point_cex, point_lwd=point_lwd, ci_lwd=ci_lwd,
    ref_lwd=ref_lwd, ref_lty=ref_lty, ref_col=ref_col,
    arrow_length=arrow_length, zebra=zebra, zebra_fill=zebra_fill,
    zebra_by=zebra_by, label_width=label_width, forest_width=forest_width,
    column_gap=column_gap, panel_gap=panel_gap,
    panel_forest_ratio=panel_forest_ratio,
    label_indent=label_indent, label_wrap=label_wrap,
    width=width, height=height, dpi=dpi, file=file
  )

  if (identical(type, "multioutcome")) {
    d <- .r4vn_tf_data(data)
    specs <- .r4vn_tf_outcome_specs(outcomes, d)
    if (is.null(specs) || !length(specs)) stop("`outcomes` is required for multi-outcome mode.", call. = FALSE)
    if (is.null(predictors)) stop("`predictors` is required for multi-outcome mode.", call. = FALSE)
    panel_list <- list()
    long <- list()
    for (i in seq_along(specs)) {
      sp <- specs[[i]]
      est_i <- if (!is.null(sp$estimate)) sp$estimate else estimate[1L]
      or_i <- if (!is.null(sp$or)) isTRUE(sp$or) else isTRUE(or)
      rr_i <- if (!is.null(sp$rr)) isTRUE(sp$rr) else isTRUE(rr)
      pr_i <- if (!is.null(sp$pr)) isTRUE(sp$pr) else isTRUE(pr)
      irr_i <- if (!is.null(sp$irr)) isTRUE(sp$irr) else isTRUE(irr)
      ev_i <- if (!is.null(sp$event)) sp$event else event
      fail_i <- if (!is.null(sp$failure)) sp$failure else failure
      time_i <- if (!is.null(sp$time)) sp$time else NULL
      args <- list(
        outcome=sp$outcome, predictors=predictors, data=d,
        time=time_i, event=ev_i, failure=fail_i,
        type="regression", crude=crude, adjusted=adjusted, multi=multi,
        or=or_i, rr=rr_i, pr=pr_i, irr=irr_i, estimate=est_i,
        ci=ci, sample=sample, per=per, per_labels=per_labels,
        xmin=NULL, xmax=NULL, ticks=NULL, log=NULL, arrows=arrows,
        row_layout=row_layout, layout=layout, row_spacing=row_spacing,
        model_row_gap=model_row_gap, group_gap=group_gap, order=order,
        reference=reference, pvalue=pvalue, global_p=global_p,
        show_n=show_n, show_events=show_events,
        show_model_label=show_model_label, p_layout=p_layout,
        effect_digit=effect_digit, p_digit=p_digit,
        labels=labels, level_labels=level_labels,
        lang=lang, text=text, model_labels=model_labels,
        template=template, grid=grid, font_family=font_family,
        base_size=base_size, label_cex=label_cex, header_cex=header_cex,
        axis_cex=axis_cex, model_cex=model_cex,
        colors=colors, fills=fills, pch=pch, lty=lty,
        point_cex=point_cex, point_lwd=point_lwd, ci_lwd=ci_lwd,
        ref_lwd=ref_lwd, ref_lty=ref_lty, ref_col=ref_col,
        arrow_length=arrow_length, zebra=FALSE,
        label_width=label_width, forest_width=forest_width,
        column_gap=column_gap, label_indent=label_indent, label_wrap=label_wrap,
        show=FALSE, console=FALSE
      )
      p <- do.call(tabforest, args)
      p$panel_label <- sp$label
      panel_list[[sp$label]] <- p
      z <- p$table
      z$outcome_panel <- sp$label
      z$effect_type <- p$effect
      long[[length(long) + 1L]] <- z
    }
    keys0 <- panel_list[[1L]]$model_keys
    if (any(vapply(panel_list, function(z) !identical(z$model_keys, keys0), logical(1)))) stop("All multi-outcome panels must request the same model groups (crude/adjusted/multi).", call. = FALSE)
    rows0 <- panel_list[[1L]]$rows
    tx <- .r4vn_tf_text_full(lang, text)
    dat <- data.frame(.id=seq_len(nrow(rows0)), stringsAsFactors=FALSE)
    dat[[tx$characteristic]] <- rows0$label
    for (nm in names(panel_list)) {
      pd <- panel_list[[nm]]$data
      if (nrow(pd) != nrow(rows0)) stop("Multi-outcome panels produced incompatible row structures.", call. = FALSE)
      extra <- pd[, -1L, drop=FALSE]
      names(extra) <- paste(nm, names(extra), sep=" | ")
      dat <- data.frame(dat, extra, check.names=FALSE, stringsAsFactors=FALSE)
    }
    dat$.id <- NULL
    output <- list(data=dat, table=do.call(rbind, long), rows=rows0,
                   panels=panel_list, models=lapply(panel_list, function(z) z$models),
                   effect=vapply(panel_list, function(z) z$effect, character(1)),
                   null=vapply(panel_list, function(z) z$null, numeric(1)),
                   model_keys=keys0, settings=common_settings,
                   analysis=list(mode="multioutcome", outcomes=specs),
                   metadata=panel_list[[1L]]$metadata, call=call)
    class(output) <- c("r4vn_tabforest_multi", "r4vn_tabforest")
    if (isTRUE(console)) print(output$table, row.names=FALSE)
    if (isTRUE(show) || !is.null(file)) plot(output, file=file, width=width, height=height, dpi=dpi)
    return(invisible(output))
  }

  if (identical(type, "subgroup")) {
    d <- .r4vn_tf_data(data)
    outcome_expr <- substitute(outcome)
    outcome_name <- .r4vn_tf_name(outcome_expr, d, env, "outcome")
    time_name <- .r4vn_tf_name(substitute(time), d, env, "time", optional=TRUE)
    if (is.null(predictor)) stop("`predictor` is required for subgroup mode.", call. = FALSE)
    predictor_spec <- .r4vn_tf_spec(predictor, d, "predictor")
    if (nrow(predictor_spec) != 1L) stop("`predictor` must contain exactly one main exposure in subgroup mode.", call. = FALSE)
    subgroup_spec <- .r4vn_tf_spec(subgroup, d, "subgroup")
    effect <- .r4vn_tf_effect_type(d, outcome_name, time_name, or, rr, pr, irr, estimate)
    if (effect %in% c("OR","RR","PR") && is.null(event)) {
      lev <- .r4vn_tf_levels(d[[outcome_name]])
      if (length(lev) == 2L) event <- tail(lev,1L)
    }
    if (identical(effect,"HR") && is.null(failure)) {
      st <- d[[outcome_name]]; lev <- .r4vn_tf_levels(st)
      if (is.numeric(st) && any(st==1,na.rm=TRUE)) failure <- 1 else if (length(lev)) failure <- tail(lev,1L)
    }
    z <- .r4vn_tf_subgroup_build(d, outcome_name, time_name,
                                 predictor_spec, subgroup_spec,
                                 adjusted=adjusted, multi=multi,
                                 effect=effect, event=event, failure=failure,
                                 ci=ci, sample=sample, per=per,
                                 labels=labels, level_labels=level_labels)
    tx <- .r4vn_tf_text_full(lang, text)
    effect_key <- if (identical(effect,"Beta")) "beta" else effect
    if (is.null(effect_title)) {
      effect_title <- tx[[effect_key]]
      if (!isTRUE(all.equal(ci,.95))) effect_title <- sub("95%",paste0(format(100*ci,trim=TRUE),"%"),effect_title,fixed=TRUE)
    }
    if (is.null(label_title)) label_title <- tx$subgroup
    if (is.null(axis_title)) axis_title <- if (identical(effect,"Beta")) "Beta" else effect
    ratio <- effect %in% c("OR","RR","PR","IRR","HR")
    if (is.null(log)) log <- ratio
    common_settings$text_resolved <- tx
    common_settings$effect_title <- effect_title
    common_settings$label_title <- label_title
    common_settings$axis_title <- axis_title
    common_settings$log <- log
    publication <- .r4vn_tf_subgroup_publication(z$rows, z$estimates, tx, effect_title,
                                                  pvalue=pvalue, show_interaction_p=show_interaction_p,
                                                  effect_digit=effect_digit, p_digit=p_digit,
                                                  show_n=show_n, show_events=show_events)
    output <- list(data=publication, table=z$estimates, rows=z$rows,
                   models=z$models, interactions=z$interactions,
                   effect=effect, null=if (ratio) 1 else 0,
                   scale=if (isTRUE(log)) "log" else "linear",
                   model_keys="Subgroup", settings=common_settings,
                   analysis=list(mode="subgroup", outcome=outcome_name,
                                 time=time_name, event=event, failure=failure,
                                 predictor=predictor_spec$variable[1L],
                                 subgroups=subgroup_spec$variable,
                                 adjusted=z$adjusted_spec,
                                 common_sample=z$common_sample),
                   metadata=list(predictor=predictor_spec, subgroup=subgroup_spec), call=call)
    class(output) <- c("r4vn_tabforest_subgroup", "r4vn_tabforest")
    if (isTRUE(console)) print(output$table,row.names=FALSE)
    if (isTRUE(show) || !is.null(file)) plot(output,file=file,width=width,height=height,dpi=dpi)
    return(invisible(output))
  }

  expr <- substitute(outcome)
  obj <- if (!missing(outcome)) tryCatch(eval(expr, envir=env), error=function(e) NULL) else NULL
  object_mode <- !is.null(obj) && inherits(obj, c("r4vn_surv","r4vn_tabmulti","r4vn_stat","r4vn_result","lm","glm","coxph"))
  analysis <- list(mode=if (object_mode) "object" else "data")

  if (object_mode) {
    z <- .r4vn_tf_object(obj, select=select, ci=ci)
    estimates <- z$estimates; effect <- z$effect; models <- z$models
    model_keys <- z$model_keys; source_data <- z$data; focal_spec <- z$spec
    if (!is.null(source_data) && !is.null(focal_spec) && nrow(focal_spec) && all(focal_spec$variable %in% names(source_data))) {
      rows <- .r4vn_tf_rows(source_data, focal_spec, estimates, labels, level_labels,
                            per=NULL, per_labels=NULL, lang=lang, reference=reference)
    } else rows <- .r4vn_tf_rows_from_estimates(estimates, labels, level_labels, reference=reference)
    if (!isTRUE(reference)) estimates <- estimates[!estimates$reference,,drop=FALSE]
    analysis$source_class <- class(obj)[1L]; analysis$select <- select
  } else {
    d <- .r4vn_tf_data(data)
    outcome_name <- .r4vn_tf_name(expr,d,env,"outcome")
    time_name <- .r4vn_tf_name(substitute(time),d,env,"time",optional=TRUE)
    focal_spec <- .r4vn_tf_spec(predictors,d,"predictors")
    if (!is.null(order)) {
      order <- as.character(order)
      idx <- c(match(order,focal_spec$variable,nomatch=0L),which(!focal_spec$variable %in% order)); idx <- idx[idx>0L]
      focal_spec <- focal_spec[idx,,drop=FALSE]
    }
    effect <- .r4vn_tf_effect_type(d,outcome_name,time_name,or,rr,pr,irr,estimate)
    if (identical(effect,"HR") && is.null(time_name)) stop("HR requires `time`.",call.=FALSE)
    if (effect %in% c("OR","RR","PR") && is.null(event)) {
      lev_y <- .r4vn_tf_levels(d[[outcome_name]]); if (length(lev_y)==2L) event <- tail(lev_y,1L)
    }
    if (identical(effect,"HR") && is.null(failure)) {
      status0 <- d[[outcome_name]]; lev_s <- .r4vn_tf_levels(status0)
      if (is.numeric(status0) && any(status0==1,na.rm=TRUE)) failure <- 1 else if (length(lev_s)) failure <- tail(lev_s,1L)
    }
    if (!isTRUE(crude) && (isFALSE(adjusted)||is.null(adjusted)) && (isFALSE(multi)||is.null(multi))) stop("Request at least one model: `crude=TRUE`, `adjusted=...`, or `multi=...`.",call.=FALSE)
    z <- .r4vn_tf_build_raw(d,outcome_name,time_name,focal_spec,adjusted,multi,effect,event,failure,ci,crude,sample,per)
    estimates <- z$estimates; models <- z$models; model_keys <- z$model_keys; source_data <- d
    rows <- .r4vn_tf_rows(d,focal_spec,estimates,labels,level_labels,per,per_labels,lang,reference)
    if (!isTRUE(reference)) estimates <- estimates[!estimates$reference,,drop=FALSE]
    analysis$outcome <- outcome_name; analysis$time <- time_name; analysis$event <- event; analysis$failure <- failure
    analysis$common_sample <- z$common_sample; analysis$analysis_n <- z$analysis_n
    analysis$adjusted <- z$adjusted_spec; analysis$adjusted_all <- z$adjusted_all; analysis$multi <- z$multi_spec
  }

  effect <- unname(as.character(effect)[1L]); effect <- if (toupper(effect)=="BETA") "Beta" else toupper(effect)
  ratio <- effect %in% c("OR","RR","PR","IRR","HR"); null <- if (ratio) 1 else 0
  if (is.null(log)) log <- ratio
  if (!is.logical(log) || length(log)!=1L || is.na(log)) stop("`log` must be TRUE, FALSE, or NULL.",call.=FALSE)
  if (isTRUE(log) && any(estimates$estimate[!estimates$reference] <= 0,na.rm=TRUE)) stop("Log forest axes require positive estimates.",call.=FALSE)

  model_keys <- unique(as.character(model_keys)); display_models <- model_keys
  tx <- .r4vn_tf_text_full(lang,text)
  base_labels <- c(Crude=tx$crude,Adjusted=tx$adjusted,Multivariable=tx$multi)
  for (i in seq_along(display_models)) if (display_models[i] %in% names(base_labels)) display_models[i] <- base_labels[[display_models[i]]]
  if (!is.null(model_labels)) {
    if (is.null(names(model_labels))) display_models[seq_len(min(length(display_models),length(model_labels)))] <- as.character(model_labels)[seq_len(min(length(display_models),length(model_labels)))]
    else for (i in seq_along(model_keys)) if (model_keys[i] %in% names(model_labels)) display_models[i] <- as.character(model_labels[[model_keys[i]]])[1L]
  }
  if (is.null(label_title)) label_title <- tx$characteristic
  effect_key <- if (identical(effect,"Beta")) "beta" else effect
  if (is.null(effect_title)) {
    effect_title <- tx[[effect_key]]
    if (!isTRUE(all.equal(ci,.95))) effect_title <- sub("95%",paste0(format(100*ci,trim=TRUE),"%"),effect_title,fixed=TRUE)
  }
  if (is.null(axis_title)) axis_title <- if (identical(effect,"Beta")) "Beta" else effect

  common_settings$text_resolved <- tx
  common_settings$display_models <- stats::setNames(display_models,model_keys)
  common_settings$effect_title <- effect_title
  common_settings$label_title <- label_title
  common_settings$axis_title <- axis_title
  common_settings$log <- log

  publication_data <- .r4vn_tf_publication_data(rows,estimates,model_keys,
                                                 common_settings$display_models,effect_title,tx,
                                                 effect_digit,p_digit,pvalue,global_p,
                                                 show_n,show_events)
  output <- list(data=publication_data,table=estimates,rows=rows,models=models,
                 effect=effect,null=null,scale=if (isTRUE(log)) "log" else "linear",
                 model_keys=model_keys,settings=common_settings,analysis=analysis,
                 metadata=focal_spec,call=call)
  class(output) <- "r4vn_tabforest"
  if (isTRUE(console)) print(estimates,row.names=FALSE)
  if (isTRUE(show) || !is.null(file)) plot(output,file=file,width=width,height=height,dpi=dpi)
  invisible(output)
}


.r4vn_tf_refresh_settings <- function(x, dots = list()) {
  s <- x$settings
  for (nm in names(dots)) s[[nm]] <- dots[[nm]]
  if (is.null(s$lang)) s$lang <- "en"
  tx <- .r4vn_tf_text_full(match.arg(s$lang, c("en","vi")), s$text)
  s$text_resolved <- tx
  wording_changed <- any(c("lang","text") %in% names(dots))
  if (is.null(s$label_title) || (wording_changed && !"label_title" %in% names(dots))) {
    s$label_title <- if (inherits(x,"r4vn_tabforest_subgroup")) tx$subgroup else tx$characteristic
  }
  if (length(x$effect) == 1L) {
    key <- if (identical(x$effect,"Beta")) "beta" else x$effect
    if (is.null(s$effect_title) || (wording_changed && !"effect_title" %in% names(dots))) s$effect_title <- tx[[key]]
    if (is.null(s$axis_title)) s$axis_title <- if (identical(x$effect,"Beta")) "Beta" else x$effect
  }
  if (!is.null(x$model_keys)) {
    keys <- x$model_keys
    dm <- keys
    base <- c(Crude=tx$crude,Adjusted=tx$adjusted,Multivariable=tx$multi)
    for (i in seq_along(dm)) if (dm[i] %in% names(base)) dm[i] <- base[[dm[i]]]
    if (!is.null(s$model_labels)) {
      ml <- s$model_labels
      if (is.null(names(ml))) dm[seq_len(min(length(dm),length(ml)))] <- as.character(ml)[seq_len(min(length(dm),length(ml)))]
      else for (i in seq_along(keys)) if (keys[i] %in% names(ml)) dm[i] <- as.character(ml[[keys[i]]])[1L]
    }
    s$display_models <- stats::setNames(dm,keys)
  }
  s
}

.r4vn_tf_open_plot <- function(s, nrows, vertical_span = NULL, file = NULL,
                               width = NULL, height = NULL, dpi = NULL) {
  if (!is.null(file)) s$file <- file
  if (!is.null(width)) s$width <- width
  if (!is.null(height)) s$height <- height
  if (!is.null(dpi)) s$dpi <- dpi
  w <- suppressWarnings(as.numeric(s$width)[1L]); if (!is.finite(w) || w <= 0) w <- 12
  h <- s$height
  if (is.null(h) || !is.finite(as.numeric(h)[1L]) || as.numeric(h)[1L] <= 0) {
    base_span <- if (is.null(vertical_span) || !is.finite(vertical_span)) nrows else vertical_span
    h <- max(4.8, 2.7 + .31 * base_span)
  } else h <- as.numeric(h)[1L]
  dpi0 <- suppressWarnings(as.numeric(s$dpi)[1L]); if (!is.finite(dpi0) || dpi0 <= 0) dpi0 <- 300
  opened <- FALSE
  if (!is.null(s$file) && nzchar(as.character(s$file)[1L])) {
    .r4vn_tf_device(as.character(s$file)[1L],w,h,dpi0)
    opened <- TRUE
  }
  list(settings=s,width=w,height=h,dpi=dpi0,opened=opened)
}

.r4vn_tf_draw_axis <- function(forest_left, forest_right, axis_y, header_bottom,
                               range_obj, null, ref_col, ref_lwd, ref_lty,
                               grid = "major", axis_cex = 1, axis_title = NULL) {
  mapx <- function(v) forest_left + (range_obj$trans(v)-range_obj$tmin)/(range_obj$tmax-range_obj$tmin) * (forest_right-forest_left)
  if (grid %in% c("major","both") && length(range_obj$ticks)) {
    for (tk in range_obj$ticks) graphics::segments(mapx(tk),axis_y+.18,mapx(tk),header_bottom,col="gray90",lwd=.7)
  }
  nx <- mapx(null)
  if (is.finite(nx) && nx >= forest_left && nx <= forest_right) graphics::segments(nx,axis_y+.18,nx,header_bottom,col=ref_col,lwd=ref_lwd,lty=ref_lty)
  graphics::segments(forest_left,axis_y,forest_right,axis_y,lwd=.8)
  for (tk in range_obj$ticks) {
    xx <- mapx(tk)
    graphics::segments(xx,axis_y,xx,axis_y-.10,lwd=.8)
    lab <- if (abs(tk)>=1000 || (abs(tk)>0 && abs(tk)<.01)) format(tk,scientific=TRUE,digits=2) else formatC(tk,format="fg",digits=4,flag="#")
    lab <- sub("\\.?0+$","",lab)
    graphics::text(xx,axis_y-.25,labels=lab,cex=axis_cex,adj=c(.5,1))
  }
  if (!is.null(axis_title) && nzchar(as.character(axis_title)[1L])) graphics::text((forest_left+forest_right)/2,axis_y-.62,labels=axis_title,cex=axis_cex)
  mapx
}

.r4vn_tf_draw_ci <- function(z, py, mapx, xmin, xmax, forest_left, forest_right,
                             col, fill, pch, point_cex, point_lwd,
                             ci_lwd, lty, arrows, arrow_length) {
  if (!nrow(z) || !is.finite(z$estimate[1L]) || isTRUE(z$reference[1L])) return(FALSE)
  lo <- z$lower[1L]; hi <- z$upper[1L]; ee <- z$estimate[1L]
  if (!all(is.finite(c(lo,hi,ee)))) return(FALSE)
  lo_clip <- max(lo,xmin); hi_clip <- min(hi,xmax)
  if (hi_clip >= xmin && lo_clip <= xmax && lo_clip <= hi_clip) graphics::segments(mapx(lo_clip),py,mapx(hi_clip),py,col=col,lwd=ci_lwd,lty=lty)
  left_clip <- lo < xmin; right_clip <- hi > xmax
  if (isTRUE(arrows) && left_clip) graphics::arrows(forest_left+.012,py,forest_left,py,length=arrow_length,angle=28,code=2,col=col,lwd=ci_lwd)
  if (isTRUE(arrows) && right_clip) graphics::arrows(forest_right-.012,py,forest_right,py,length=arrow_length,angle=28,code=2,col=col,lwd=ci_lwd)
  if (ee >= xmin && ee <= xmax) graphics::points(mapx(ee),py,pch=pch,cex=point_cex,col=col,bg=fill,lwd=point_lwd)
  else if (isTRUE(arrows)) {
    if (ee < xmin) graphics::arrows(forest_left+.025,py,forest_left,py,length=arrow_length,angle=28,code=2,col=col,lwd=ci_lwd*1.15)
    if (ee > xmax) graphics::arrows(forest_right-.025,py,forest_right,py,length=arrow_length,angle=28,code=2,col=col,lwd=ci_lwd*1.15)
  }
  left_clip || right_clip || ee < xmin || ee > xmax
}

#' Plot an R4VN regression forest object
#'
#' Re-draws a `tabforest()` object without refitting models. All graphical
#' settings may be overridden here, which is useful for trying different journal
#' layouts after the analysis is finalized.
#'
#' @param x An `r4vn_tabforest` object.
#' @param ... Any plotting option accepted by `tabforest()`.
#' @param file,width,height,dpi Optional output overrides.
#' @return `x`, invisibly.
#' @export
plot.r4vn_tabforest <- function(x, ..., file=NULL, width=NULL, height=NULL, dpi=NULL) {
  if (inherits(x,"r4vn_tabforest_multi")) return(plot.r4vn_tabforest_multi(x,...,file=file,width=width,height=height,dpi=dpi))
  if (inherits(x,"r4vn_tabforest_subgroup")) return(plot.r4vn_tabforest_subgroup(x,...,file=file,width=width,height=height,dpi=dpi))
  if (!inherits(x,"r4vn_tabforest")) stop("`x` must be an r4vn_tabforest object.",call.=FALSE)
  s <- .r4vn_tf_refresh_settings(x,list(...))
  if (!is.null(file)) s$file <- file; if (!is.null(width)) s$width <- width; if (!is.null(height)) s$height <- height; if (!is.null(dpi)) s$dpi <- dpi
  tx <- s$text_resolved; est <- x$table; rows <- x$rows; keys <- x$model_keys
  nmodels <- length(keys); if (!nmodels) stop("The forest contains no model columns.",call.=FALSE)
  ratio <- x$effect %in% c("OR","RR","PR","IRR","HR")
  use_log <- isTRUE(s$log)
  rng <- .r4vn_tf_axis_range(est,x$null,s$xmin,s$xmax,s$ticks,use_log,ratio)

  dm <- s$display_models; if (is.null(dm)) dm <- stats::setNames(keys,keys)
  row_layout_plot <- s$row_layout
  if (is.null(row_layout_plot) || identical(row_layout_plot,"auto")) row_layout_plot <- if (nmodels > 1L) "modelrows" else "compact"
  rows_plot <- .r4vn_tf_expand_rows(rows,keys,dm,row_layout_plot,s$show_model_label)
  row_y <- .r4vn_tf_row_positions(rows_plot,s$row_spacing,s$model_row_gap,s$group_gap)
  nrows <- nrow(rows_plot); y_top <- max(row_y)+1.7; y_bottom <- -.15
  vertical_span <- max(row_y)-min(row_y)+5
  op <- .r4vn_tf_open_plot(s,nrows,vertical_span,file,width,height,dpi); s <- op$settings; opened <- op$opened
  oldpar <- graphics::par(no.readonly=TRUE)
  on.exit({try(graphics::par(oldpar),silent=TRUE); if (opened) try(grDevices::dev.off(),silent=TRUE)},add=TRUE)
  family <- if (is.null(s$font_family)) "" else as.character(s$font_family)[1L]
  graphics::par(mar=c(1.6,1,2.8,1),xaxs="i",yaxs="i",family=family,ps=as.numeric(s$base_size))
  graphics::plot.new(); graphics::plot.window(xlim=c(0,1),ylim=c(y_bottom,y_top),xaxs="i",yaxs="i")
  graphics::rect(0,y_bottom,1,y_top,col="white",border=NA)
  if (isTRUE(s$zebra)) .r4vn_tf_draw_zebra(rows_plot,row_y,s$zebra_fill,s$zebra_by)

  lw <- as.numeric(s$label_width); fw <- as.numeric(s$forest_width)
  if (!is.finite(lw) || lw<=.12 || lw>=.60) stop("`label_width` should be between 0.12 and 0.60.",call.=FALSE)
  if (!is.finite(fw) || fw<=.12 || fw>=.60) stop("`forest_width` should be between 0.12 and 0.60.",call.=FALSE)
  forest_left <- lw; forest_right <- forest_left+fw
  if (forest_right>=.86) stop("`label_width + forest_width` leaves too little room for numbers.",call.=FALSE)
  num_left <- forest_right + as.numeric(s$column_gap); num_right <- .995
  header_y <- max(row_y)+1.0; header_bottom <- header_y-.60; axis_y <- .58
  graphics::text(.008,header_y,labels=s$label_title,adj=c(0,.5),font=2,cex=s$header_cex)
  graphics::text((forest_left+forest_right)/2,header_y,labels=s$effect_title,font=2,cex=s$header_cex)

  modelrows <- identical(row_layout_plot,"modelrows")
  if (modelrows) {
    if (identical(s$p_layout,"column") && isTRUE(s$pvalue)) {
      eff_right <- num_left + (num_right-num_left)*.76
      p_left <- eff_right
      graphics::text((num_left+eff_right)/2,header_y,labels=s$effect_title,font=2,cex=s$header_cex*.88)
      graphics::text((p_left+num_right)/2,header_y,labels=tx$p,font=2,cex=s$header_cex*.88)
    } else {
      eff_right <- num_right; p_left <- num_right
      graphics::text((num_left+num_right)/2,header_y,labels=s$effect_title,font=2,cex=s$header_cex*.88)
    }
  } else {
    model_width <- (num_right-num_left)/nmodels
    for (j in seq_along(keys)) {
      a <- num_left+(j-1)*model_width; b <- a+model_width
      graphics::text((a+b)/2,header_y+.20,labels=as.character(dm[[keys[j]]]),font=2,cex=s$header_cex*.88)
      graphics::text((a+b)/2,header_y-.22,labels=s$effect_title,font=2,cex=s$header_cex*.76)
    }
  }
  graphics::segments(.005,header_bottom,.995,header_bottom,lwd=1)
  mapx <- .r4vn_tf_draw_axis(forest_left,forest_right,axis_y,header_bottom,rng,x$null,
                             s$ref_col,s$ref_lwd,s$ref_lty,s$grid,s$axis_cex,s$axis_title)

  cols <- .r4vn_tf_resolve_named(s$colors,keys,"black")
  fills <- .r4vn_tf_resolve_named(s$fills,keys,NA)
  pchs <- as.numeric(.r4vn_tf_resolve_named(s$pch,keys,c(1,19,15,17)[seq_len(nmodels)]))
  ltys <- as.numeric(.r4vn_tf_resolve_named(s$lty,keys,1))
  pcex <- as.numeric(.r4vn_tf_resolve_named(s$point_cex,keys,1.15))
  plwd <- as.numeric(.r4vn_tf_resolve_named(s$point_lwd,keys,1))
  clwd <- as.numeric(.r4vn_tf_resolve_named(s$ci_lwd,keys,1.2))
  dodge <- if (nmodels<=1L) 0 else if (identical(s$layout,"stack")) .20*as.numeric(s$row_spacing) else .11*as.numeric(s$row_spacing)
  offsets <- if (nmodels<=1L) 0 else seq(dodge,-dodge,length.out=nmodels)
  clipped_any <- FALSE

  for (i in seq_len(nrows)) {
    r <- rows_plot[i,,drop=FALSE]; yy <- row_y[i]
    if (identical(r$row_type,"header")) {
      graphics::text(.008,yy,labels=.r4vn_tf_wrap(r$label,s$label_wrap),adj=c(0,.5),font=2,cex=s$label_cex)
      if (isTRUE(s$global_p)) {
        pieces <- character()
        for (k in keys) {
          z <- est[est$variable==r$variable & est$model==k,,drop=FALSE]
          gp <- if (nrow(z)) z$global_p[is.finite(z$global_p)] else numeric()
          if (length(gp)) pieces <- c(pieces,paste0(as.character(dm[[k]])," ",tx$p,"=",.r4vn_tf_fmt_p(gp[1L],s$p_digit)))
        }
        if (length(pieces)) graphics::text(num_left,yy,labels=paste(pieces,collapse="; "),adj=c(0,.5),cex=s$label_cex*.82)
      }
      next
    }

    lx <- if (identical(r$row_type,"level")) .008+as.numeric(s$label_indent) else .008
    label_here <- r$label
    if (modelrows && i>1L && isTRUE(rows_plot$.item_id[i]==rows_plot$.item_id[i-1L])) label_here <- ""
    graphics::text(lx,yy,labels=.r4vn_tf_wrap(label_here,s$label_wrap),adj=c(0,.5),cex=s$label_cex)
    if (modelrows && !is.na(r$.display_model) && nzchar(r$.display_model_label)) {
      graphics::text(forest_left-.012,yy,labels=r$.display_model_label,adj=c(1,.5),cex=s$model_cex,font=3)
    }

    draw_keys <- if (modelrows) r$.display_model else keys
    for (k in draw_keys) {
      if (is.na(k) || !nzchar(k)) next
      j <- match(k,keys)
      if (identical(r$row_type,"level")) z <- est[est$variable==r$variable & est$level==r$level & est$model==k,,drop=FALSE]
      else z <- est[est$variable==r$variable & (is.na(est$level)|est$level=="") & est$model==k,,drop=FALSE]
      if (!nrow(z)) next
      ref <- isTRUE(z$reference[1L]); py <- if (modelrows) yy else yy+offsets[j]
      nt <- .r4vn_tf_numeric_text(z,ref,tx,s$effect_digit,s$p_digit,s$pvalue,s$show_n,s$show_events,s$p_layout)
      if (modelrows) {
        if (identical(s$p_layout,"column") && isTRUE(s$pvalue)) {
          graphics::text(num_left,yy,labels=nt$effect,adj=c(0,.5),cex=s$label_cex*.84)
          if (nzchar(nt$p)) graphics::text((p_left+num_right)/2,yy,labels=nt$p,cex=s$label_cex*.84)
        } else graphics::text(num_left,yy,labels=nt$effect,adj=c(0,.5),cex=s$label_cex*.84)
      } else {
        model_width <- (num_right-num_left)/nmodels; a <- num_left+(j-1)*model_width; b <- a+model_width
        if (identical(s$p_layout,"column") && isTRUE(s$pvalue)) {
          split <- a+(b-a)*.77
          graphics::text(a+.003,py,labels=nt$effect,adj=c(0,.5),cex=s$label_cex*.78)
          if (nzchar(nt$p)) graphics::text((split+b)/2,py,labels=nt$p,cex=s$label_cex*.78)
        } else graphics::text((a+b)/2,py,labels=nt$effect,cex=s$label_cex*.78)
      }
      clipped_any <- .r4vn_tf_draw_ci(z,py,mapx,rng$xmin,rng$xmax,forest_left,forest_right,
                                       cols[[k]],fills[[k]],pchs[j],pcex[j],plwd[j],clwd[j],ltys[j],
                                       s$arrows,s$arrow_length) || clipped_any
    }
  }

  if (!is.null(s$title) && nzchar(as.character(s$title)[1L])) graphics::title(main=as.character(s$title)[1L],cex.main=1.10)
  if (!is.null(s$subtitle) && nzchar(as.character(s$subtitle)[1L])) graphics::mtext(as.character(s$subtitle)[1L],side=3,line=.15,cex=.90)
  notes <- character()
  if (!is.null(s$caption) && nzchar(as.character(s$caption)[1L])) notes <- c(notes,as.character(s$caption)[1L])
  if (is.character(s$note) && length(s$note) && nzchar(s$note[1L])) notes <- c(notes,s$note[1L]) else if (isTRUE(s$note) && clipped_any) notes <- c(notes,tx$arrow_note)
  if (length(notes)) graphics::mtext(paste(notes,collapse="\n"),side=1,line=.2,adj=0,cex=.78)
  invisible(x)
}

#' @export
plot.r4vn_tabforest_multi <- function(x, ..., file=NULL, width=NULL, height=NULL, dpi=NULL) {
  if (!inherits(x,"r4vn_tabforest_multi")) stop("`x` must be an r4vn_tabforest_multi object.",call.=FALSE)
  s <- .r4vn_tf_refresh_settings(x,list(...))
  if (!is.null(file)) s$file<-file; if (!is.null(width)) s$width<-width; if (!is.null(height)) s$height<-height; if (!is.null(dpi)) s$dpi<-dpi
  tx <- s$text_resolved; panels <- x$panels; pnames <- names(panels); np <- length(panels); keys <- x$model_keys; nmodels <- length(keys)
  dm <- s$display_models; if (is.null(dm)) dm <- stats::setNames(keys,keys)
  row_layout_plot <- s$row_layout
  if (is.null(row_layout_plot) || identical(row_layout_plot,"auto")) row_layout_plot <- if (nmodels > 1L) "modelrows" else "compact"
  rows_plot <- .r4vn_tf_expand_rows(x$rows,keys,dm,row_layout_plot,s$show_model_label)
  row_y <- .r4vn_tf_row_positions(rows_plot,s$row_spacing,s$model_row_gap,s$group_gap)
  nrows <- nrow(rows_plot); y_top <- max(row_y)+1.85; y_bottom <- -.18
  op <- .r4vn_tf_open_plot(s,nrows,max(row_y)-min(row_y)+5,file,width,height,dpi); s<-op$settings; opened<-op$opened
  oldpar <- graphics::par(no.readonly=TRUE)
  on.exit({try(graphics::par(oldpar),silent=TRUE);if(opened)try(grDevices::dev.off(),silent=TRUE)},add=TRUE)
  family <- if(is.null(s$font_family))"" else as.character(s$font_family)[1L]
  graphics::par(mar=c(1.8,1,3,1),xaxs="i",yaxs="i",family=family,ps=as.numeric(s$base_size))
  graphics::plot.new();graphics::plot.window(xlim=c(0,1),ylim=c(y_bottom,y_top),xaxs="i",yaxs="i")
  graphics::rect(0,y_bottom,1,y_top,col="white",border=NA)
  if(isTRUE(s$zebra)) .r4vn_tf_draw_zebra(rows_plot,row_y,s$zebra_fill,s$zebra_by)

  lw <- as.numeric(s$label_width); if(!is.finite(lw)||lw<=.12||lw>=.55) stop("For multi-outcome plots, `label_width` should be between 0.12 and 0.55.",call.=FALSE)
  pg <- as.numeric(s$panel_gap); if(!is.finite(pg)||pg<0)pg<-.012
  usable <- 1-lw-pg*(np-1L)-.01; if(usable<=.25) stop("Too many panels for the current `width`/`label_width`.",call.=FALSE)
  pw <- usable/np; fr <- as.numeric(s$panel_forest_ratio); if(!is.finite(fr)||fr<.35||fr>.80)fr<-.58
  header_y <- max(row_y)+1.08; header_bottom <- header_y-.63; axis_y <- .60
  graphics::text(.008,header_y,labels=if(is.null(s$label_title))tx$characteristic else s$label_title,adj=c(0,.5),font=2,cex=s$header_cex)
  graphics::segments(.005,header_bottom,.995,header_bottom,lwd=1)

  cols <- .r4vn_tf_resolve_named(s$colors,keys,"black"); fills <- .r4vn_tf_resolve_named(s$fills,keys,NA)
  pchs <- as.numeric(.r4vn_tf_resolve_named(s$pch,keys,c(1,19,15,17)[seq_len(nmodels)])); ltys <- as.numeric(.r4vn_tf_resolve_named(s$lty,keys,1))
  pcex <- as.numeric(.r4vn_tf_resolve_named(s$point_cex,keys,1.15)); plwd <- as.numeric(.r4vn_tf_resolve_named(s$point_lwd,keys,1)); clwd <- as.numeric(.r4vn_tf_resolve_named(s$ci_lwd,keys,1.2))
  dodge <- if(nmodels<=1L)0 else if(identical(s$layout,"stack")) .20*as.numeric(s$row_spacing) else .11*as.numeric(s$row_spacing)
  offsets <- if(nmodels<=1L)0 else seq(dodge,-dodge,length.out=nmodels)
  panel_geom <- vector("list",np); clipped_any <- FALSE

  for(q in seq_along(panels)) {
    p <- panels[[q]]; nm <- pnames[q]
    left <- lw+(q-1L)*(pw+pg); right <- left+pw
    fleft <- left+.015*pw; fright <- left+fr*pw
    nleft <- fright+.025*pw; nright <- right-.010*pw
    effect <- p$effect; ratio <- effect %in% c("OR","RR","PR","IRR","HR")
    logq <- .r4vn_tf_panel_value(s$log,nm,q,ratio); if(is.null(logq))logq<-ratio
    xminq <- .r4vn_tf_panel_value(s$xmin,nm,q,NULL); xmaxq <- .r4vn_tf_panel_value(s$xmax,nm,q,NULL)
    ticksq <- .r4vn_tf_panel_value(s$ticks,nm,q,NULL)
    rng <- .r4vn_tf_axis_range(p$table,p$null,xminq,xmaxq,ticksq,isTRUE(logq),ratio)
    graphics::text((left+right)/2,header_y+.28,labels=nm,font=2,cex=s$header_cex*.90)
    effect_key <- if (identical(effect,"Beta")) "beta" else effect
    etitle <- if (!is.null(s$effect_title)) s$effect_title else tx[[effect_key]]
    graphics::text((fleft+fright)/2,header_y-.18,labels=etitle,font=2,cex=s$header_cex*.72)
    axis_q <- if (!is.null(s$axis_title)) s$axis_title else if (identical(effect,"Beta")) "Beta" else effect
    mapx <- .r4vn_tf_draw_axis(fleft,fright,axis_y,header_bottom,rng,p$null,s$ref_col,s$ref_lwd,s$ref_lty,s$grid,s$axis_cex,axis_q)
    if(q>1L) graphics::segments(left-pg/2,axis_y-.35,left-pg/2,header_y+.55,col="gray85",lwd=.7)
    panel_geom[[q]] <- list(fleft=fleft,fright=fright,nleft=nleft,nright=nright,rng=rng,mapx=mapx,panel=p)
  }

  modelrows <- identical(row_layout_plot,"modelrows")
  for(i in seq_len(nrows)) {
    r <- rows_plot[i,,drop=FALSE]; yy<-row_y[i]
    if(identical(r$row_type,"header")) {
      graphics::text(.008,yy,labels=.r4vn_tf_wrap(r$label,s$label_wrap),adj=c(0,.5),font=2,cex=s$label_cex)
      next
    }
    lx <- if(identical(r$row_type,"level")) .008+as.numeric(s$label_indent) else .008
    lab <- r$label; if(modelrows && i>1L && isTRUE(rows_plot$.item_id[i]==rows_plot$.item_id[i-1L])) lab<-""
    graphics::text(lx,yy,labels=.r4vn_tf_wrap(lab,s$label_wrap),adj=c(0,.5),cex=s$label_cex)
    if(modelrows && !is.na(r$.display_model) && nzchar(r$.display_model_label)) graphics::text(lw-.010,yy,labels=r$.display_model_label,adj=c(1,.5),cex=s$model_cex,font=3)
    draw_keys <- if(modelrows) r$.display_model else keys
    for(q in seq_along(panels)) {
      pgm <- panel_geom[[q]]; p <- pgm$panel; est <- p$table
      for(k in draw_keys) {
        if(is.na(k)||!nzchar(k))next; j<-match(k,keys)
        if(identical(r$row_type,"level")) z<-est[est$variable==r$variable & est$level==r$level & est$model==k,,drop=FALSE]
        else z<-est[est$variable==r$variable & (is.na(est$level)|est$level=="") & est$model==k,,drop=FALSE]
        if(!nrow(z))next
        py <- if(modelrows)yy else yy+offsets[j]; ref<-isTRUE(z$reference[1L])
        nt <- .r4vn_tf_numeric_text(z,ref,tx,s$effect_digit,s$p_digit,s$pvalue,s$show_n,s$show_events,"inline")
        if(!modelrows && nmodels>1L) nt$effect <- paste0(as.character(dm[[k]]),": ",nt$effect)
        graphics::text(pgm$nleft,py,labels=nt$effect,adj=c(0,.5),cex=s$label_cex*.70)
        clipped_any <- .r4vn_tf_draw_ci(z,py,pgm$mapx,pgm$rng$xmin,pgm$rng$xmax,pgm$fleft,pgm$fright,
                                         cols[[k]],fills[[k]],pchs[j],pcex[j],plwd[j],clwd[j],ltys[j],s$arrows,s$arrow_length) || clipped_any
      }
    }
  }
  if(!is.null(s$title)&&nzchar(as.character(s$title)[1L]))graphics::title(main=as.character(s$title)[1L],cex.main=1.08)
  if(!is.null(s$subtitle)&&nzchar(as.character(s$subtitle)[1L]))graphics::mtext(as.character(s$subtitle)[1L],side=3,line=.15,cex=.90)
  notes<-character();if(!is.null(s$caption)&&nzchar(as.character(s$caption)[1L]))notes<-c(notes,as.character(s$caption)[1L])
  if(is.character(s$note)&&length(s$note)&&nzchar(s$note[1L]))notes<-c(notes,s$note[1L]) else if(isTRUE(s$note)&&clipped_any)notes<-c(notes,tx$arrow_note)
  if(length(notes))graphics::mtext(paste(notes,collapse="\n"),side=1,line=.25,adj=0,cex=.75)
  invisible(x)
}

#' @export
plot.r4vn_tabforest_subgroup <- function(x, ..., file=NULL, width=NULL, height=NULL, dpi=NULL) {
  if(!inherits(x,"r4vn_tabforest_subgroup"))stop("`x` must be an r4vn_tabforest_subgroup object.",call.=FALSE)
  s <- .r4vn_tf_refresh_settings(x,list(...)); if(!is.null(file))s$file<-file;if(!is.null(width))s$width<-width;if(!is.null(height))s$height<-height;if(!is.null(dpi))s$dpi<-dpi
  tx<-s$text_resolved;est<-x$table;rows<-x$rows;ratio<-x$effect %in% c("OR","RR","PR","IRR","HR");use_log<-isTRUE(s$log)
  rng<-.r4vn_tf_axis_range(est,x$null,s$xmin,s$xmax,s$ticks,use_log,ratio)
  rows_plot<-rows;rows_plot$.item_id<-seq_len(nrow(rows));rows_plot$.row_group<-if(nrow(rows)) cumsum(c(TRUE,rows$variable[-1L]!=rows$variable[-nrow(rows)])) else integer()
  row_y<-.r4vn_tf_row_positions(rows_plot,s$row_spacing,s$model_row_gap,s$group_gap);nrows<-nrow(rows_plot);y_top<-max(row_y)+1.7;y_bottom<--.15
  op<-.r4vn_tf_open_plot(s,nrows,max(row_y)-min(row_y)+5,file,width,height,dpi);s<-op$settings;opened<-op$opened
  oldpar<-graphics::par(no.readonly=TRUE);on.exit({try(graphics::par(oldpar),silent=TRUE);if(opened)try(grDevices::dev.off(),silent=TRUE)},add=TRUE)
  family<-if(is.null(s$font_family))"" else as.character(s$font_family)[1L]
  graphics::par(mar=c(1.6,1,2.8,1),xaxs="i",yaxs="i",family=family,ps=as.numeric(s$base_size));graphics::plot.new();graphics::plot.window(xlim=c(0,1),ylim=c(y_bottom,y_top),xaxs="i",yaxs="i")
  graphics::rect(0,y_bottom,1,y_top,col="white",border=NA);if(isTRUE(s$zebra)).r4vn_tf_draw_zebra(rows_plot,row_y,s$zebra_fill,s$zebra_by)
  lw<-as.numeric(s$label_width);fw<-as.numeric(s$forest_width);forest_left<-lw;forest_right<-forest_left+fw
  if(forest_right>=.72)stop("For subgroup plots, reduce `label_width` or `forest_width` to leave room for p-value columns.",call.=FALSE)
  rem_left<-forest_right+as.numeric(s$column_gap);rem_right<-.995
  showp<-isTRUE(s$pvalue);showip<-isTRUE(s$show_interaction_p)
  if(showp&&showip){eff_right<-rem_left+(rem_right-rem_left)*.58;p_right<-rem_left+(rem_right-rem_left)*.78;ip_right<-rem_right}
  else if(showp){eff_right<-rem_left+(rem_right-rem_left)*.72;p_right<-rem_right;ip_right<-rem_right}
  else if(showip){eff_right<-rem_left+(rem_right-rem_left)*.70;p_right<-eff_right;ip_right<-rem_right}
  else {eff_right<-rem_right;p_right<-rem_right;ip_right<-rem_right}
  header_y<-max(row_y)+1.0;header_bottom<-header_y-.60;axis_y<-.58
  graphics::text(.008,header_y,labels=s$label_title,adj=c(0,.5),font=2,cex=s$header_cex)
  graphics::text((forest_left+forest_right)/2,header_y,labels=s$effect_title,font=2,cex=s$header_cex)
  graphics::text((rem_left+eff_right)/2,header_y,labels=s$effect_title,font=2,cex=s$header_cex*.82)
  if(showp)graphics::text((eff_right+p_right)/2,header_y,labels=tx$p,font=2,cex=s$header_cex*.82)
  if(showip)graphics::text((max(p_right,eff_right)+ip_right)/2,header_y,labels=tx$interaction_p,font=2,cex=s$header_cex*.78)
  graphics::segments(.005,header_bottom,.995,header_bottom,lwd=1)
  mapx<-.r4vn_tf_draw_axis(forest_left,forest_right,axis_y,header_bottom,rng,x$null,s$ref_col,s$ref_lwd,s$ref_lty,s$grid,s$axis_cex,s$axis_title)
  col<-.r4vn_tf_resolve_named(s$colors,"Subgroup","black")[[1L]];fill<-.r4vn_tf_resolve_named(s$fills,"Subgroup",NA)[[1L]];pch0<-as.numeric(.r4vn_tf_resolve_named(s$pch,"Subgroup",15)[[1L]])
  lty0<-as.numeric(.r4vn_tf_resolve_named(s$lty,"Subgroup",1)[[1L]]);pcex<-as.numeric(.r4vn_tf_resolve_named(s$point_cex,"Subgroup",1.1)[[1L]]);plwd<-as.numeric(.r4vn_tf_resolve_named(s$point_lwd,"Subgroup",1)[[1L]]);clwd<-as.numeric(.r4vn_tf_resolve_named(s$ci_lwd,"Subgroup",1.2)[[1L]])
  clipped_any<-FALSE
  for(i in seq_len(nrows)){
    r<-rows_plot[i,,drop=FALSE];yy<-row_y[i]
    if(identical(r$row_type,"header")){
      graphics::text(.008,yy,labels=.r4vn_tf_wrap(r$label,s$label_wrap),adj=c(0,.5),font=2,cex=s$label_cex)
      if(showip){z<-est[est$subgroup_variable==r$variable,,drop=FALSE];ip<-z$interaction_p[is.finite(z$interaction_p)];if(length(ip))graphics::text((max(p_right,eff_right)+ip_right)/2,yy,labels=.r4vn_tf_fmt_p(ip[1L],s$p_digit),cex=s$label_cex*.86)}
      next
    }
    graphics::text(.008+as.numeric(s$label_indent),yy,labels=.r4vn_tf_wrap(r$label,s$label_wrap),adj=c(0,.5),cex=s$label_cex)
    z<-est[est$subgroup_variable==r$variable & est$subgroup_level==r$level,,drop=FALSE]
    if(!nrow(z))next
    nt<-.r4vn_tf_numeric_text(z,FALSE,tx,s$effect_digit,s$p_digit,FALSE,s$show_n,s$show_events,"column")
    graphics::text(rem_left,yy,labels=nt$effect,adj=c(0,.5),cex=s$label_cex*.82)
    if(showp&&is.finite(z$p[1L]))graphics::text((eff_right+p_right)/2,yy,labels=.r4vn_tf_fmt_p(z$p[1L],s$p_digit),cex=s$label_cex*.84)
    clipped_any<-.r4vn_tf_draw_ci(z,yy,mapx,rng$xmin,rng$xmax,forest_left,forest_right,col,fill,pch0,pcex,plwd,clwd,lty0,s$arrows,s$arrow_length)||clipped_any
  }
  if(!is.null(s$title)&&nzchar(as.character(s$title)[1L]))graphics::title(main=as.character(s$title)[1L],cex.main=1.08)
  if(!is.null(s$subtitle)&&nzchar(as.character(s$subtitle)[1L]))graphics::mtext(as.character(s$subtitle)[1L],side=3,line=.15,cex=.90)
  notes<-character();if(!is.null(s$caption)&&nzchar(as.character(s$caption)[1L]))notes<-c(notes,as.character(s$caption)[1L]);if(is.character(s$note)&&length(s$note)&&nzchar(s$note[1L]))notes<-c(notes,s$note[1L]) else if(isTRUE(s$note)&&clipped_any)notes<-c(notes,tx$arrow_note);if(length(notes))graphics::mtext(paste(notes,collapse="\n"),side=1,line=.2,adj=0,cex=.78)
  invisible(x)
}

#' @export
print.r4vn_tabforest <- function(x, ...) { plot(x,...); invisible(x) }

#' @export
as.data.frame.r4vn_tabforest <- function(x, ...) x$data

Try the R4VN package in your browser

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

R4VN documentation built on Sept. 30, 2026, 5:13 p.m.