//! 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)\"/>"));
}
}
neiam
/bismuth
MOTHER-FUCKING-BISMUTH
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
13.6 KiB · text
History
487bb12