13.6 KiB · text History 487bb12
//! Card rendering: highlighted code inside a KDE window on a backdrop.
//!
//! Everything is composed as one SVG document. The editor preview and the
//! embed page inline it, the SVG export is the same document with its fonts
//! embedded, and the PNG export rasterises it with resvg. One renderer means
//! that what the preview shows is exactly what gets exported.
pub mod chrome;
pub mod fonts;
pub mod highlight;
pub mod palette;
pub mod svg;

use std::fmt::Write;

use rand::distr::{Alphanumeric, SampleString};
use resvg::{tiny_skia, usvg};
use serde::{Deserialize, Serialize};
use unicode_width::UnicodeWidthStr;

use chrome::Shadow;
use highlight::Span;
use palette::Palette;
use svg::{esc, round_path};

pub const FONT_SIZE: f32 = 14.0;
pub const LINE_HEIGHT: f32 = 21.0;
const PAD_X: f32 = 18.0;
const PAD_Y: f32 = 14.0;
const MIN_COLS: usize = 24;

/// Hard limits keep a hostile paste from producing a gigapixel PNG.
pub const MAX_CODE_BYTES: usize = 32 * 1024;
pub const MAX_LINES: usize = 300;
pub const MAX_COLS: usize = 200;
pub const MAX_PADDING: u32 = 160;
pub const DEFAULT_PADDING: u32 = 48;

pub const AUTO_PALETTE: &str = "auto";

/// Everything that decides how a card looks. Stored as-is on a snippet and
/// accepted from the editor form, so unknown ids fall back to defaults at
/// render time rather than being rejected.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct CardSpec {
    pub code: String,
    pub title: String,
    pub language: String,
    pub chrome: String,
    pub palette: String,
    pub highlight: String,
    pub transparent: bool,
    pub padding: u32,
    pub line_numbers: bool,
}

impl Default for CardSpec {
    fn default() -> Self {
        CardSpec {
            code: SAMPLE_CODE.to_string(),
            title: "bismuth.rs — Konsole".to_string(),
            language: "Rust".to_string(),
            chrome: chrome::DEFAULT_CHROME.to_string(),
            palette: AUTO_PALETTE.to_string(),
            highlight: highlight::DEFAULT_HL.to_string(),
            transparent: false,
            padding: DEFAULT_PADDING,
            line_numbers: false,
        }
    }
}

pub const SAMPLE_CODE: &str = r#"use std::collections::HashMap;

/// Bismuth crystals grow in stair-stepped hoppers.
fn hopper(steps: u32) -> Vec<u32> {
    (1..=steps).map(|n| n * n).collect()
}

fn main() {
    let mut seen: HashMap<&str, u32> = HashMap::new();
    for word in ["kwm", "keramik", "plastik", "oxygen", "breeze"] {
        *seen.entry(word).or_default() += 1;
    }
    println!("{:?} {:?}", hopper(5), seen.len());
}"#;

impl CardSpec {
    /// The decoration actually drawn: unknown ids fall back to the default.
    pub fn chrome(&self) -> &'static chrome::Chrome {
        chrome::get(&self.chrome).unwrap_or_else(|| chrome::get(chrome::DEFAULT_CHROME).unwrap())
    }

    /// The palette actually used: `auto` (or anything unknown) takes the
    /// decoration's own era scheme.
    pub fn palette(&self) -> &'static Palette {
        palette::get(&self.palette).unwrap_or_else(|| palette::get(self.chrome().default_palette).unwrap())
    }

    /// Clamp everything to the render limits.
    pub fn sanitize(mut self) -> Self {
        if self.code.len() > MAX_CODE_BYTES {
            let mut cut = MAX_CODE_BYTES;
            while !self.code.is_char_boundary(cut) {
                cut -= 1;
            }
            self.code.truncate(cut);
        }
        self.title = self.title.chars().take(200).collect();
        self.padding = self.padding.min(MAX_PADDING);
        self
    }
}

/// Whether the SVG is going into an HTML page (fonts come from the page) or
/// out the door as a file (fonts embedded, XML prolog).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Output {
    Inline,
    Standalone,
}

fn line_width(spans: &[Span]) -> usize {
    spans.iter().map(|s| UnicodeWidthStr::width(s.text.as_str())).sum()
}

/// Cut a line at `MAX_COLS` display columns.
fn clip_line(spans: &mut Vec<Span>) {
    let mut used = 0usize;
    let mut keep = 0usize;
    for span in spans.iter_mut() {
        let w = UnicodeWidthStr::width(span.text.as_str());
        if used + w <= MAX_COLS {
            used += w;
            keep += 1;
            continue;
        }
        let mut cut = String::new();
        for ch in span.text.chars() {
            let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
            if used + cw > MAX_COLS {
                break;
            }
            used += cw;
            cut.push(ch);
        }
        span.text = cut;
        keep += 1;
        break;
    }
    spans.truncate(keep);
}

pub fn render_svg(spec: &CardSpec, output: Output) -> String {
    let chrome = spec.chrome();
    let pal = spec.palette();
    let ins = chrome::insets(chrome.id);
    let mut hl = highlight::highlight(&spec.code, &spec.language, &spec.highlight, pal);
    hl.lines.truncate(MAX_LINES);
    for line in hl.lines.iter_mut() {
        clip_line(line);
    }

    let adv = fonts::mono_advance() * FONT_SIZE;
    let n = hl.lines.len();
    let cols = hl.lines.iter().map(|l| line_width(l)).max().unwrap_or(0).max(MIN_COLS);
    let gutter = if spec.line_numbers { n.to_string().len() as f32 * adv + 16.0 } else { 0.0 };

    let content_w = PAD_X * 2.0 + gutter + cols as f32 * adv;
    let content_h = PAD_Y * 2.0 + n as f32 * LINE_HEIGHT;
    let win_w = (content_w + ins.left + ins.right).max(ins.min_width).ceil();
    let win_h = (content_h + ins.top + ins.bottom).ceil();
    let pad = spec.padding.min(MAX_PADDING) as f32;
    let (total_w, total_h) = (win_w + 2.0 * pad, win_h + 2.0 * pad);
    let (wx, wy) = (pad, pad);

    let uid = format!("b{}", Alphanumeric.sample_string(&mut rand::rng(), 6));
    let mut out = String::with_capacity(16 * 1024);

    if output == Output::Standalone {
        out.push_str(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
    }
    // `xml:space` keeps indentation for resvg and standalone files; browsers
    // inlining the SVG into HTML ignore it and need `white-space: pre`.
    // Where the code grid sits, in SVG units. The editor reads these off the
    // inline preview to lay its in-window textarea exactly over the text.
    let (cx, cy) = (wx + ins.left, wy + ins.top);
    let (cw, ch) = (win_w - ins.left - ins.right, win_h - ins.top - ins.bottom);
    let tx = cx + PAD_X + gutter;
    let _ = write!(
        out,
        r#"<svg xmlns="http://www.w3.org/2000/svg" width="{total_w}" height="{total_h}" viewBox="0 0 {total_w} {total_h}" xml:space="preserve" style="white-space:pre" class="bismuth-card" data-chrome="{}" data-palette="{}""#,
        chrome.id, pal.id
    );
    if output == Output::Inline {
        let _ = write!(
            out,
            r#" data-code-x="{tx}" data-code-y="{}" data-code-right="{}" data-code-bottom="{}" data-line-height="{LINE_HEIGHT}" data-font-size="{FONT_SIZE}" data-code-fg="{}""#,
            cy + PAD_Y,
            cx + cw,
            cy + ch,
            hl.foreground
        );
    }
    out.push('>');
    if output == Output::Standalone {
        let _ = write!(out, "<style>{}</style>", fonts::embedded_font_css());
    }
    if !spec.title.is_empty() {
        let _ = write!(out, "<title>{}</title>", esc(&spec.title));
    }

    // Backdrop + shadow.
    let shadow = chrome::shadow(chrome.id);
    out.push_str("<defs>");
    let _ = write!(
        out,
        r#"<linearGradient id="{uid}-bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="{}"/><stop offset="1" stop-color="{}"/></linearGradient>"#,
        pal.backdrop.0, pal.backdrop.1
    );
    if shadow == Shadow::Soft {
        let _ = write!(
            out,
            r#"<filter id="{uid}-sh" x="-25%" y="-25%" width="150%" height="160%"><feGaussianBlur stdDeviation="{}"/></filter>"#,
            (pad / 3.0).clamp(2.0, 14.0)
        );
    }
    out.push_str("</defs>");
    if !spec.transparent {
        let _ = write!(out, r#"<rect width="{total_w}" height="{total_h}" fill="url(#{uid}-bg)"/>"#);
    }
    let outline = round_path(wx, wy, win_w, win_h, ins.radius_top, ins.radius_bottom);
    match shadow {
        Shadow::Soft if pad > 0.0 => {
            let _ = write!(
                out,
                r#"<path d="{outline}" transform="translate(0 {})" fill="black" fill-opacity="0.45" filter="url(#{uid}-sh)"/>"#,
                (pad / 5.0).clamp(1.0, 10.0)
            );
        }
        Shadow::Hard if pad > 0.0 => {
            let off = (pad / 8.0).clamp(2.0, 6.0);
            let _ = write!(
                out,
                r#"<path d="{outline}" transform="translate({off} {off})" fill="black" fill-opacity="0.35"/>"#
            );
        }
        _ => {}
    }

    // Decoration.
    let ctx = chrome::Ctx { x: wx, y: wy, w: win_w, h: win_h, p: pal, title: &spec.title, uid: &uid };
    chrome::draw(chrome.id, &mut out, &ctx);

    // Content area. Corners round only where no frame sits outside them.
    let rt = if ins.top == 0.0 { ins.radius_top } else { 0.0 };
    let rb = if ins.bottom == 0.0 && ins.left == 0.0 { ins.radius_bottom } else { 0.0 };
    let _ = write!(out, r#"<path d="{}" fill="{}"/>"#, round_path(cx, cy, cw, ch, rt, rb), hl.background);

    let baseline = |i: usize| cy + PAD_Y + i as f32 * LINE_HEIGHT + LINE_HEIGHT * 0.5 + FONT_SIZE * 0.35;
    let _ = write!(
        out,
        r#"<g font-family="'{}', monospace" font-size="{FONT_SIZE}">"#,
        fonts::MONO_FAMILY
    );
    if spec.line_numbers {
        let gx = cx + PAD_X + gutter - 16.0;
        let _ = write!(out, r#"<g fill="{}" text-anchor="end">"#, hl.gutter);
        for i in 0..n {
            let _ = write!(out, r#"<text x="{gx}" y="{}">{}</text>"#, baseline(i), i + 1);
        }
        out.push_str("</g>");
    }
    for (i, spans) in hl.lines.iter().enumerate() {
        if spans.iter().all(|s| s.text.trim().is_empty()) {
            continue;
        }
        let _ = write!(out, r#"<text x="{tx}" y="{}" xml:space="preserve" style="white-space:pre">"#, baseline(i));
        for s in spans {
            let _ = write!(out, r#"<tspan fill="{}""#, s.fg);
            if s.bold {
                out.push_str(r#" font-weight="bold""#);
            }
            if s.italic {
                out.push_str(r#" font-style="italic""#);
            }
            let _ = write!(out, ">{}</tspan>", esc(&s.text));
        }
        out.push_str("</text>");
    }
    out.push_str("</g>");

    chrome::overlay(chrome.id, &mut out, &ctx);
    out.push_str("</svg>");
    out
}

pub const MAX_SCALE: f32 = 4.0;
const MAX_PIXELS: u64 = 40_000_000;

/// Rasterise a card. `scale` multiplies the SVG's own size (2 = retina).
pub fn render_png(svg: &str, scale: f32) -> anyhow::Result<Vec<u8>> {
    let scale = scale.clamp(0.5, MAX_SCALE);
    let opt = usvg::Options {
        fontdb: fonts::FONTDB.clone(),
        font_family: fonts::MONO_FAMILY.to_string(),
        ..Default::default()
    };
    let tree = usvg::Tree::from_str(svg, &opt)?;
    let size = tree
        .size()
        .to_int_size()
        .scale_by(scale)
        .ok_or_else(|| anyhow::anyhow!("card scales to zero size"))?;
    if size.width() as u64 * size.height() as u64 > MAX_PIXELS {
        anyhow::bail!("card too large to rasterise at {scale}x");
    }
    let mut pixmap = tiny_skia::Pixmap::new(size.width(), size.height())
        .ok_or_else(|| anyhow::anyhow!("could not allocate {}x{} pixmap", size.width(), size.height()))?;
    resvg::render(&tree, tiny_skia::Transform::from_scale(scale, scale), &mut pixmap.as_mut());
    Ok(pixmap.encode_png()?)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Every combination must at least parse; rasterising all of them is
    /// slow, so only each decoration and each palette once.
    #[test]
    fn every_chrome_and_palette_renders() {
        let opt = usvg::Options { fontdb: fonts::FONTDB.clone(), ..Default::default() };
        for c in chrome::CHROMES {
            for p in palette::PALETTES.iter() {
                let spec = CardSpec { chrome: c.id.into(), palette: p.id.into(), line_numbers: true, ..CardSpec::default() };
                let svg = render_svg(&spec, Output::Inline);
                usvg::Tree::from_str(&svg, &opt).unwrap_or_else(|e| panic!("{} / {}: {e}", c.id, p.id));
            }
            let svg = render_svg(&CardSpec { chrome: c.id.into(), ..CardSpec::default() }, Output::Standalone);
            assert!(render_png(&svg, 1.0).unwrap().starts_with(b"\x89PNG"));
        }
        for p in palette::PALETTES.iter() {
            let svg = render_svg(&CardSpec { palette: p.id.into(), ..CardSpec::default() }, Output::Inline);
            assert!(render_png(&svg, 1.0).unwrap().starts_with(b"\x89PNG"));
        }
    }

    #[test]
    fn auto_palette_follows_chrome() {
        let spec = CardSpec { chrome: "keramik".into(), ..CardSpec::default() };
        assert_eq!(spec.palette().id, "keramik");
        let spec = CardSpec { chrome: "bogus".into(), palette: "bogus".into(), ..CardSpec::default() };
        assert_eq!(spec.chrome().id, chrome::DEFAULT_CHROME);
        assert_eq!(spec.palette().id, "breeze-dark");
    }

    #[test]
    fn hostile_input_still_renders() {
        let spec = CardSpec {
            code: "<script>&\u{0}\u{1b}[31m\n".repeat(5000) + &"x".repeat(10_000),
            title: "\"><svg onload=alert(1)>".into(),
            ..CardSpec::default()
        }
        .sanitize();
        let svg = render_svg(&spec, Output::Standalone);
        assert!(!svg.contains("<script>"));
        assert!(!svg.contains("<svg onload"));
        render_png(&svg, 1.0).unwrap();
    }

    #[test]
    fn transparent_drops_backdrop() {
        let spec = CardSpec { transparent: true, ..CardSpec::default() };
        let svg = render_svg(&spec, Output::Inline);
        assert!(!svg.contains("-bg)\"/>"));
    }
}