claude-scriptorium 0.1.4

Render Claude Code sessions as self-contained HTML
Documentation
//! Encodes the embedded web fonts into the folio's `@font-face` blocks.
//!
//! The faces are compile-time constants, so the base64 they travel as is one
//! too: doing it here keeps megabytes of encoding out of every render, and out
//! of the binary's startup. `just fonts` vendors the woff2 files this reads.
//!
//! Two blocks are written, not one. A folio carries the cut faces
//! (`src/fonts/cut/`, ~1/5 the bytes) unless its own text reaches a character
//! the cut dropped, and then it carries the whole ones. `dropped.txt` names
//! those characters, and is compiled in beside the blocks so the renderer can
//! tell which folio is which.

use std::{env, fs, path::Path};

use base64::{Engine, engine::general_purpose::STANDARD};

/// One embedded face: the family and posture the stylesheet asks for, the
/// weight range the variable file covers, and the vendored woff2 file.
///
/// Junicode 2 (serif body, roman + italic) and Fira Code (monospace) are
/// variable, so one file per posture spans every weight; UnifrakturCook is the
/// single-weight blackletter used for headings and dropped versals.
const FACES: [Face; 4] = [
    Face {
        family: "Junicode",
        style: "normal",
        weight: "300 700",
        file: "JunicodeVF-Roman.woff2",
    },
    Face {
        family: "Junicode",
        style: "italic",
        weight: "300 700",
        file: "JunicodeVF-Italic.woff2",
    },
    Face {
        family: "UnifrakturCook",
        style: "normal",
        weight: "400",
        file: "UnifrakturCook.woff2",
    },
    Face {
        family: "Fira Code",
        style: "normal",
        weight: "300 700",
        file: "FiraCode-VF.woff2",
    },
];

struct Face {
    family: &'static str,
    style: &'static str,
    weight: &'static str,
    file: &'static str,
}

fn main() {
    let fonts = Path::new("src/fonts");
    let out = Path::new(&env::var("OUT_DIR").expect("cargo sets OUT_DIR")).to_path_buf();

    write(&out.join("font-faces-whole.css"), &face_block(fonts));
    write(
        &out.join("font-faces-cut.css"),
        &face_block(&fonts.join("cut")),
    );
    write(
        &out.join("dropped.rs"),
        &dropped(&fonts.join("cut/dropped.txt")),
    );
}

/// The `@font-face` rules for one cut of the faces, each with its woff2 inlined
/// as a base64 data URI.
fn face_block(dir: &Path) -> String {
    let mut block = String::new();
    for face in FACES {
        let path = dir.join(face.file);
        println!("cargo::rerun-if-changed={}", path.display());
        let woff2 = fs::read(&path).unwrap_or_else(|error| {
            panic!("reading {}: {error}", path.display());
        });
        block.push_str(&format!(
            "@font-face{{font-family:\"{}\";font-style:{};font-weight:{};font-display:swap;\
             src:url(data:font/woff2;base64,{}) format(\"woff2\")}}",
            face.family,
            face.style,
            face.weight,
            STANDARD.encode(woff2),
        ));
    }
    block
}

/// Compiles `dropped.txt` into a sorted table of inclusive codepoint ranges for
/// the renderer to search. Malformed input is a build failure rather than a
/// silently empty table, since an empty one would quietly stop the whole-face
/// fallback from ever firing.
fn dropped(path: &Path) -> String {
    println!("cargo::rerun-if-changed={}", path.display());
    let text = fs::read_to_string(path)
        .unwrap_or_else(|error| panic!("reading {}: {error}", path.display()));

    let mut ranges = Vec::new();
    for line in text.lines() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        let (low, high) = line
            .split_once('-')
            .unwrap_or_else(|| panic!("{}: {line:?} is not a `LOW-HIGH` range", path.display()));
        let parse = |value: &str| {
            u32::from_str_radix(value, 16)
                .unwrap_or_else(|error| panic!("{}: {value:?}: {error}", path.display()))
        };
        ranges.push((parse(low), parse(high)));
    }
    assert!(
        ranges.windows(2).all(|pair| pair[0].1 < pair[1].0),
        "{}: ranges must be sorted and disjoint for a binary search",
        path.display()
    );

    let entries: Vec<String> = ranges
        .iter()
        .map(|(low, high)| format!("({low:#06X}, {high:#06X})"))
        .collect();
    format!(
        "/// Codepoints an embedded face carries whole but not cut, as sorted,\n\
         /// disjoint, inclusive ranges. Generated by `build.rs` from\n\
         /// `src/fonts/cut/dropped.txt`.\n\
         pub const DROPPED: &[(u32, u32)] = &[{}];\n",
        entries.join(", ")
    )
}

fn write(path: &Path, contents: &str) {
    fs::write(path, contents).unwrap_or_else(|error| panic!("writing {}: {error}", path.display()));
}