Files
figcraft/crates/engine/src/dev/codegen/css.rs
T
45e5a25370 Escape backslashes and line breaks in CSS font-family export (#1752)
* Escape backslashes and line breaks in CSS font-family export

Found while testing crates/engine/src/dev/codegen/css.rs:223 (text_decls, the font-family put)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Escape quotes and < in CSS font-family export

A double quote in a family name is escaped instead of turned into a single quote (CodeRabbit on
#1752), and < is written as \3C so a name can't end the generated page's <style> block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-31 10:37:13 -04:00

326 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::{
ContentAlign, CrossAlign, Document, EffectKind, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, PADDING, PaintKind,
TextAlignH, TextCase, TextDecoration, Variable, css_borders, num,
};
// ------------------------------------------------------------------ CSS
/// The CSS custom properties generated code uses: name → fallback value, in first-use order.
#[derive(Default)]
pub(crate) struct Tokens {
vars: Vec<(String, String)>,
/// Name variables as Tailwind theme variables (`--color-accent`), not by their CSS name.
pub tailwind: bool,
/// The file's theme names (computed on first use).
theme: Option<crate::tailwind::Theme>,
}
impl Tokens {
/// `var(--name, value)`, remembering the variable.
fn var(&mut self, doc: &Document, variable: &Variable, value: String) -> String {
let name =
if self.tailwind { self.theme.get_or_insert_with(|| crate::tailwind::Theme::new(doc)).name(variable) } else { css_var_of(variable) };
let out = format!("var({name}, {value})");
if !self.vars.iter().any(|(n, _)| *n == name) {
self.vars.push((name, value));
}
out
}
/// A `:root` rule declaring the variables (empty without any).
pub fn root_rule(&self) -> String {
if self.vars.is_empty() {
return String::new();
}
let decls: String = self.vars.iter().map(|(n, v)| format!(" {n}: {v};\n")).collect();
format!(":root {{\n{decls}}}\n")
}
}
/// A variable's CSS custom property name: "Colors/Brand 500" → `--colors-brand-500`.
pub fn css_var_name(variable: &str) -> String {
let mut out = String::from("--");
for c in variable.chars().flat_map(char::to_lowercase).take(80) {
if c.is_alphanumeric() || c == '_' {
out.push(c);
} else if !out.ends_with('-') {
out.push('-');
}
}
let trimmed = out.trim_end_matches('-');
if trimmed.len() <= 2 { "--variable".into() } else { trimmed.to_string() }
}
/// A variable's CSS custom property: its Web code syntax (`var(--x)` or `--x`) when it has one,
/// else made from its name.
pub fn css_var_of(v: &Variable) -> String {
let Some(web) = v.code_syntax.get("WEB").map(|w| w.trim()) else { return css_var_name(&v.name) };
let inner = web.strip_prefix("var(").map_or(web, |r| r.split([',', ')']).next().unwrap_or("").trim());
let valid = inner.len() > 2 && inner.starts_with("--") && inner.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_');
if valid { inner.chars().take(80).collect() } else { css_var_name(inner) }
}
/// `value`, or a reference to the variable bound to the first of `fields` that has one.
pub(super) fn tok(doc: &Document, n: &Node, fields: &[&str], value: String, t: &mut Tokens) -> String {
match fields.iter().find_map(|f| crate::devinspect::bound_variable(doc, n, f)) {
Some(v) => t.var(doc, v, value),
None => value,
}
}
pub(super) fn css(doc: &Document, id: NodeId) -> String {
let Some(n) = doc.node(id) else { return String::new() };
let mut t = Tokens::default();
let decls = css_decls(doc, n, &mut t, (false, false));
format!("/* {} */\n{decls}", n.name.replace("*/", "* /"))
}
/// The CSS declarations for `n`'s own look and size, one per line; `hug` leaves out the width
/// and/or height (they follow the content). Bound variables become `var(--name, value)`.
pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bool)) -> String {
let px = |v: f64| format!("{}px", num(v));
let mut out = String::new();
let mut put = |key: &str, value: String| out.push_str(&format!("{key}: {value};\n"));
if !hug.0 {
put("width", tok(doc, n, &["width"], px(n.width), t));
}
if !hug.1 {
put("height", tok(doc, n, &["height"], px(n.height), t));
}
if let Some(l) = &n.layout {
layout_decls(doc, n, l, t, &mut put);
}
if matches!(n.kind, NodeKind::Ellipse { .. }) {
put("border-radius", "50%".into());
} else if let Some(r) = n.corners.uniform().filter(|r| *r > 0.0) {
put("border-radius", tok(doc, n, &["cornerRadius"], px(r), t));
} else if n.corners.radii.iter().any(|r| *r > 0.0) {
let fields = ["topLeftRadius", "topRightRadius", "bottomRightRadius", "bottomLeftRadius"];
let corners: Vec<String> = n.corners.radii.iter().zip(fields).map(|(r, f)| tok(doc, n, &[f, "cornerRadius"], px(*r), t)).collect();
put("border-radius", corners.join(" "));
}
if n.opacity < 1.0 {
put("opacity", tok(doc, n, &["opacity"], num(f64::from(n.opacity)), t));
}
if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
put("mix-blend-mode", n.blend.css().into());
}
// Rotation turns counter-clockwise about the top-left corner (the position given); CSS
// rotate turns clockwise.
let rot = figcraft_geom::rotation_degrees(n.transform);
if rot.abs() > 0.01 && rot.is_finite() {
put("transform", format!("rotate({}deg)", num(-rot)));
put("transform-origin", "top left".into());
}
for (i, p) in n.fills.iter().enumerate().filter(|(_, p)| p.visible) {
match &p.kind {
PaintKind::Solid { color } => {
let key = if matches!(n.kind, NodeKind::Text(_)) { "color" } else { "background" };
let c = color.with_alpha(color.a * p.opacity).to_css();
// A variable binds the first paint's colour.
put(key, if i == 0 { tok(doc, n, &["fill"], c, t) } else { c });
}
PaintKind::Gradient(g) => put("background", css_gradient(g, n.width, n.height)),
PaintKind::Image(_) => put("background", "url(image.png) center / cover".into()),
PaintKind::Pattern(_) => put("background", "url(pattern.png) repeat".into()),
}
}
for (key, value) in css_borders(doc, n, t) {
put(&key, value);
}
effect_decls(n, &mut put);
if let NodeKind::Text(tx) = &n.kind {
text_decls(doc, n, tx, t, &mut put);
}
out
}
/// The flex declarations of auto layout `l` on `n`.
fn layout_decls(doc: &Document, n: &Node, l: &figcraft_doc::AutoLayout, t: &mut Tokens, put: &mut impl FnMut(&str, String)) {
let px = |v: f64| format!("{}px", num(v));
put("display", "flex".into());
put("flex-direction", if l.mode == LayoutMode::Vertical { "column" } else { "row" }.into());
if l.wrap {
put("flex-wrap", "wrap".into());
}
put("gap", tok(doc, n, &["gap"], px(l.gap), t));
let [pt, pr, pb, pl] = l.padding;
let sides = [
tok(doc, n, &PADDING[0], px(pt), t),
tok(doc, n, &PADDING[1], px(pr), t),
tok(doc, n, &PADDING[2], px(pb), t),
tok(doc, n, &PADDING[3], px(pl), t),
];
put("padding", sides.join(" "));
let justify = match l.main_align {
MainAlign::Min => "flex-start",
MainAlign::Center => "center",
MainAlign::Max => "flex-end",
MainAlign::SpaceBetween => "space-between",
MainAlign::SpaceAround => "space-around",
MainAlign::SpaceEvenly => "space-evenly",
};
put("justify-content", justify.into());
let align = match l.cross_align {
CrossAlign::Min => "flex-start",
CrossAlign::Center => "center",
CrossAlign::Max => "flex-end",
CrossAlign::Baseline => "baseline",
};
put("align-items", align.into());
if l.wrap {
// The lines: spread, or packed where the items align (baseline: at the start).
let spread = l.content_align == ContentAlign::SpaceBetween;
put(
"align-content",
if spread {
"space-between"
} else if align == "baseline" {
"flex-start"
} else {
align
}
.into(),
);
}
}
/// Shadows and blurs of `n`.
fn effect_decls(n: &Node, put: &mut impl FnMut(&str, String)) {
let shadows: Vec<String> = n
.effects
.iter()
.filter(|e| e.visible)
.filter_map(|e| match &e.kind {
EffectKind::DropShadow(s) => {
Some(format!("{}px {}px {}px {}px {}", num(s.offset.0), num(s.offset.1), num(s.radius), num(s.spread), s.color.to_css()))
}
EffectKind::InnerShadow(s) => {
Some(format!("inset {}px {}px {}px {}px {}", num(s.offset.0), num(s.offset.1), num(s.radius), num(s.spread), s.color.to_css()))
}
_ => None,
})
.collect();
if !shadows.is_empty() {
put("box-shadow", shadows.join(", "));
}
for e in n.effects.iter().filter(|e| e.visible) {
match e.kind {
// CSS blurs are uniform: a progressive blur has no CSS equivalent and is left out.
EffectKind::LayerBlur(ref b) if !b.is_progressive() => put("filter", format!("blur({}px)", num(b.radius / 2.0))),
EffectKind::BackgroundBlur(ref b) if !b.is_progressive() => put("backdrop-filter", format!("blur({}px)", num(b.radius / 2.0))),
// CSS has no refraction: the frost is the nearest.
EffectKind::Glass(ref g) => put("backdrop-filter", format!("blur({}px)", num(g.sanitized().frost / 2.0))),
_ => {}
}
}
}
/// The type declarations of text layer `n`.
fn text_decls(doc: &Document, n: &Node, tx: &figcraft_doc::TextData, t: &mut Tokens, put: &mut impl FnMut(&str, String)) {
let px = |v: f64| format!("{}px", num(v));
let s = &tx.style;
put(
"font-family",
tok(
doc,
n,
&["fontFamily"],
format!(
"\"{}\"",
s.font_family.replace('\\', "\\\\").replace('"', "\\\"").replace('<', "\\3C ").replace("\r\n", "\\A ").replace(['\n', '\r'], "\\A ")
),
t,
),
);
put("font-size", tok(doc, n, &["fontSize"], px(s.font_size), t));
put("font-weight", s.weight().to_string());
if s.italic() {
put("font-style", "italic".into());
}
match s.line_height {
LineHeight::Pixels(v) => put("line-height", tok(doc, n, &["lineHeight"], px(v), t)),
LineHeight::Percent(p) => put("line-height", format!("{}%", num(p))),
LineHeight::Auto => {}
}
match s.letter_spacing {
LetterSpacing::Pixels(v) if v != 0.0 => put("letter-spacing", tok(doc, n, &["letterSpacing"], px(v), t)),
LetterSpacing::Percent(p) if p != 0.0 => put("letter-spacing", format!("{}em", num(p / 100.0))),
_ => {}
}
match tx.align_h {
TextAlignH::Left => {}
TextAlignH::Center => put("text-align", "center".into()),
TextAlignH::Right => put("text-align", "right".into()),
TextAlignH::Justified => put("text-align", "justify".into()),
}
match s.case {
TextCase::Original => {}
TextCase::Upper => put("text-transform", "uppercase".into()),
TextCase::Lower => put("text-transform", "lowercase".into()),
TextCase::Title => put("text-transform", "capitalize".into()),
TextCase::SmallCaps => put("font-variant", "small-caps".into()),
}
match s.decoration {
TextDecoration::None => {}
TextDecoration::Underline => put("text-decoration", "underline".into()),
TextDecoration::Strikethrough => put("text-decoration", "line-through".into()),
}
decoration_decls(s, put);
if tx.leading_trim == figcraft_doc::LeadingTrim::CapHeight {
put("text-box", "trim-both cap alphabetic".into());
}
}
fn decoration_decls(s: &figcraft_doc::TextStyle, put: &mut impl FnMut(&str, String)) {
let px = |v: f64| format!("{}px", num(v));
if s.decoration != TextDecoration::None {
let l = s.look();
let len = |m: LineHeight| match m {
LineHeight::Auto => None,
LineHeight::Pixels(v) => Some(px(v)),
LineHeight::Percent(p) => Some(format!("{}%", num(p))),
};
match l.style {
figcraft_doc::DecorationStyle::Solid => {}
figcraft_doc::DecorationStyle::Dotted => put("text-decoration-style", "dotted".into()),
figcraft_doc::DecorationStyle::Wavy => put("text-decoration-style", "wavy".into()),
}
if let Some(v) = len(l.thickness) {
put("text-decoration-thickness", v);
}
if let Some(c) = l.color {
put("text-decoration-color", c.to_css());
}
if s.decoration == TextDecoration::Underline {
if let Some(v) = len(l.offset) {
put("text-underline-offset", v);
}
// Browsers skip ink by default; the canvas only when asked.
if !l.skip_ink {
put("text-decoration-skip-ink", "none".into());
}
}
}
}
/// A gradient fill on a `w`×`h` box as a CSS background: linear along its handles' direction
/// (CSS angles: 0deg up, 90deg right), radial with its handles' radii, angular as a conic
/// gradient from its start direction.
fn css_gradient(g: &figcraft_color::Gradient, w: f64, h: f64) -> String {
let stops = g.sorted_stops().iter().map(|s| format!("{} {}%", s.color.to_css(), (s.position * 100.0).round())).collect::<Vec<_>>().join(", ");
let (dx, dy) = ((g.to.0 - g.from.0) * w, (g.to.1 - g.from.1) * h);
let angle = if dx == 0.0 && dy == 0.0 { 180.0 } else { dx.atan2(-dy).to_degrees().rem_euclid(360.0) };
let pct = |v: f64| format!("{}%", num(v * 100.0));
let at = format!("at {} {}", pct(g.from.0), pct(g.from.1));
match g.kind {
figcraft_color::GradientKind::Linear => format!("linear-gradient({}deg, {stops})", num(angle)),
figcraft_color::GradientKind::Radial | figcraft_color::GradientKind::Diamond => {
let (rx, ry) = (((g.width.0 - g.from.0) * w).hypot((g.width.1 - g.from.1) * h), dx.hypot(dy));
let rel = |r: f64, side: f64| if side > 0.0 { pct(r / side) } else { "50%".into() };
format!("radial-gradient({} {} {at}, {stops})", rel(rx, w), rel(ry, h))
}
figcraft_color::GradientKind::Angular => format!("conic-gradient(from {}deg {at}, {stops})", num(angle)),
}
}