Menu
Public repository / master
AkurAI-Framework / crates/css/src/utilities/typography.rs
Latest change 3aae9f4e85241bcb775c31f5c03094becd3da56a - Pure-Rust UI layer: akurai-ui, akurai-crypto, Tailwind-grade akurai-css by Ólafur Búi Ólafsson
//! Typography: font size/weight/family/style, tracking, leading, alignment,
//! transform, decoration, overflow/wrapping, numerics, smoothing, line clamp,
//! vertical align and list style.
//!
//! `text-{size}` emits `font-size` only (so the legacy `text-sm` ⇒
//! `font-size:0.875rem` is unchanged); pair it with `leading-*`, or use the
//! `text-sm/6` modifier to set both. Sizes are a fixed keyword list, so they
//! never collide with `text-{colour}`.
use super::value::{arbitrary_hinted, integer, looks_like_length, positive};
use super::{keyword, Utility};
const SIZES: &[(&str, &str)] = &[
("xs", "0.75rem"),
("sm", "0.875rem"),
("base", "1rem"),
("lg", "1.125rem"),
("xl", "1.25rem"),
("2xl", "1.5rem"),
("3xl", "1.875rem"),
("4xl", "2.25rem"),
("5xl", "3rem"),
("6xl", "3.75rem"),
("7xl", "4.5rem"),
("8xl", "6rem"),
("9xl", "8rem"),
];
const LEADING: &[(&str, &str)] = &[
("none", "1"),
("tight", "1.25"),
("snug", "1.375"),
("normal", "1.5"),
("relaxed", "1.625"),
("loose", "2"),
];
const TRACKING: &[(&str, &str)] = &[
("tighter", "-0.05em"),
("tight", "-0.025em"),
("normal", "0em"),
("wide", "0.025em"),
("wider", "0.05em"),
("widest", "0.1em"),
];
const KEYWORDS: &[(&str, &str)] = &[
// weight
("font-thin", "font-weight:100"),
("font-extralight", "font-weight:200"),
("font-light", "font-weight:300"),
("font-normal", "font-weight:400"),
("font-medium", "font-weight:500"),
("font-semibold", "font-weight:600"),
("font-bold", "font-weight:700"),
("font-extrabold", "font-weight:800"),
("font-black", "font-weight:900"),
// family
(
"font-sans",
"font-family:var(--font-sans,ui-sans-serif,system-ui,sans-serif)",
),
(
"font-serif",
"font-family:var(--font-serif,ui-serif,Georgia,serif)",
),
(
"font-mono",
"font-family:var(--font-mono,ui-monospace,SFMono-Regular,Menlo,Consolas,monospace)",
),
// style
("italic", "font-style:italic"),
("not-italic", "font-style:normal"),
// alignment
("text-left", "text-align:left"),
("text-center", "text-align:center"),
("text-right", "text-align:right"),
("text-justify", "text-align:justify"),
("text-start", "text-align:start"),
("text-end", "text-align:end"),
// transform
("uppercase", "text-transform:uppercase"),
("lowercase", "text-transform:lowercase"),
("capitalize", "text-transform:capitalize"),
("normal-case", "text-transform:none"),
// decoration
("underline", "text-decoration-line:underline"),
("overline", "text-decoration-line:overline"),
("line-through", "text-decoration-line:line-through"),
("no-underline", "text-decoration-line:none"),
("underline-offset-auto", "text-underline-offset:auto"),
// overflow / wrapping
(
"truncate",
"overflow:hidden;text-overflow:ellipsis;white-space:nowrap",
),
("text-ellipsis", "text-overflow:ellipsis"),
("text-clip", "text-overflow:clip"),
("text-wrap", "text-wrap:wrap"),
("text-nowrap", "text-wrap:nowrap"),
("text-balance", "text-wrap:balance"),
("text-pretty", "text-wrap:pretty"),
("break-normal", "overflow-wrap:normal;word-break:normal"),
("break-words", "overflow-wrap:break-word"),
("break-all", "word-break:break-all"),
("break-keep", "word-break:keep-all"),
// numerics / smoothing
("normal-nums", "font-variant-numeric:normal"),
("tabular-nums", "font-variant-numeric:tabular-nums"),
(
"proportional-nums",
"font-variant-numeric:proportional-nums",
),
("lining-nums", "font-variant-numeric:lining-nums"),
(
"antialiased",
"-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale",
),
(
"subpixel-antialiased",
"-webkit-font-smoothing:auto;-moz-osx-font-smoothing:auto",
),
(
"line-clamp-none",
"overflow:visible;display:block;-webkit-box-orient:horizontal;-webkit-line-clamp:none",
),
// vertical align
("align-baseline", "vertical-align:baseline"),
("align-top", "vertical-align:top"),
("align-middle", "vertical-align:middle"),
("align-bottom", "vertical-align:bottom"),
("align-text-top", "vertical-align:text-top"),
("align-text-bottom", "vertical-align:text-bottom"),
("align-sub", "vertical-align:sub"),
("align-super", "vertical-align:super"),
// lists
("list-none", "list-style-type:none"),
("list-disc", "list-style-type:disc"),
("list-decimal", "list-style-type:decimal"),
("list-inside", "list-style-position:inside"),
("list-outside", "list-style-position:outside"),
];
/// `white-space` values accepted by `whitespace-*`.
const WHITESPACE: &[&str] = &[
"normal",
"nowrap",
"pre",
"pre-line",
"pre-wrap",
"break-spaces",
];
pub fn resolve(u: &str) -> Option<Utility> {
keyword(KEYWORDS, u)
.or_else(|| text_size(u))
.or_else(|| leading(u))
.or_else(|| tracking(u, false))
.or_else(|| whitespace(u))
.or_else(|| underline_offset(u))
.or_else(|| line_clamp(u))
}
/// `-tracking-wide`.
pub fn negative(u: &str) -> Option<Utility> {
tracking(u, true)
}
/// `text-sm`, `text-sm/6`, `text-[13px]`, `text-[length:var(--x)]`.
fn text_size(u: &str) -> Option<Utility> {
let v = u.strip_prefix("text-")?;
if v.starts_with('[') {
let (hint, value) = arbitrary_hinted(v)?;
let is_size = match hint.as_deref() {
Some("length") | Some("size") => true,
None => looks_like_length(&value),
Some(_) => false,
};
return is_size.then(|| Utility::decl("font-size", &value));
}
let (size, lh) = match v.split_once('/') {
Some((size, lh)) => (size, Some(leading_value(lh)?)),
None => (v, None),
};
let (_, font_size) = SIZES.iter().find(|(k, _)| *k == size)?;
let mut decls = vec![format!("font-size:{font_size}")];
decls.extend(lh.map(|lh| format!("line-height:{lh}")));
Some(Utility::from_decls(decls))
}
/// `leading-tight` ⇒ `1.25`, `leading-6` ⇒ `1.5rem` (N × 0.25rem).
fn leading(u: &str) -> Option<Utility> {
let value = leading_value(u.strip_prefix("leading-")?)?;
Some(Utility::decl("line-height", &value).tier(1))
}
fn leading_value(v: &str) -> Option<String> {
if let Some((_, lh)) = LEADING.iter().find(|(k, _)| *k == v) {
return Some((*lh).to_string());
}
if let Some(n) = integer(v) {
return Some(format!(
"{}rem",
super::value::trim_float(f64::from(n) * 0.25)
));
}
super::value::arbitrary(v)
}
fn tracking(u: &str, neg: bool) -> Option<Utility> {
let v = u.strip_prefix("tracking-")?;
let (_, value) = TRACKING.iter().find(|(k, _)| *k == v)?;
let value = match (neg, value.strip_prefix('-')) {
(false, _) => (*value).to_string(),
(true, Some(positive)) => positive.to_string(),
(true, None) => format!("-{value}"),
};
Some(Utility::decl("letter-spacing", &value))
}
fn whitespace(u: &str) -> Option<Utility> {
let v = u.strip_prefix("whitespace-")?;
WHITESPACE
.contains(&v)
.then(|| Utility::decl("white-space", v).tier(1))
}
fn underline_offset(u: &str) -> Option<Utility> {
let n = integer(u.strip_prefix("underline-offset-")?)?;
Some(Utility::decl("text-underline-offset", &format!("{n}px")))
}
fn line_clamp(u: &str) -> Option<Utility> {
let n = positive(u.strip_prefix("line-clamp-")?)?;
Some(Utility::body(&format!(
"overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:{n}"
)))
}
#[cfg(test)]
mod tests {
use super::*;
fn decl(u: &str) -> Option<String> {
resolve(u).map(|x| x.declaration())
}
#[test]
fn sizes_are_keywords_only() {
assert_eq!(decl("text-accent"), None);
assert_eq!(decl("text-[#fff]"), None);
assert_eq!(decl("text-[var(--x)]"), None);
assert_eq!(
decl("text-[length:var(--x)]").as_deref(),
Some("font-size:var(--x)")
);
assert_eq!(
decl("text-xs/[18px]").as_deref(),
Some("font-size:0.75rem;line-height:18px")
);
assert_eq!(decl("text-sm/x"), None);
}
#[test]
fn tracking_negation_flips_sign() {
let n = |u: &str| negative(u).map(|x| x.declaration());
assert_eq!(
n("tracking-wide").as_deref(),
Some("letter-spacing:-0.025em")
);
assert_eq!(
n("tracking-tight").as_deref(),
Some("letter-spacing:0.025em")
);
}
#[test]
fn misc() {
assert_eq!(decl("leading-3").as_deref(), Some("line-height:0.75rem"));
assert_eq!(decl("leading-10").as_deref(), Some("line-height:2.5rem"));
assert_eq!(
decl("whitespace-pre-line").as_deref(),
Some("white-space:pre-line")
);
assert_eq!(decl("whitespace-wat"), None);
assert_eq!(decl("line-clamp-0"), None);
}
}