The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok' crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party license archives, README provenance, and the required 'Based on Grok Build Open Source' attribution, now sourced from version_attribution.txt). Wire-visible renames (both sides in this repo, changed in lockstep): - Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode). - Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* / _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps a read-side alias for the legacy '_x.ai/session/update' method so existing updates.jsonl histories load; writes emit only the new name (both directions test-pinned). - Agent types grok-build* → kigi* with a documented legacy-prefix alias at resolution time so persisted sessions keep resolving. - ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build implementation dirs renamed to kigi*. - x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes grokday/groknight → kigiday/kiginight (old persisted values fall back to the default theme), web_fetch allowlist xAI hosts → kimi.com + moonshot platforms, changelog CDN → this repo, grok-build changelog archives deleted. - BYOK default endpoint removed: [endpoints] api_base_url is now truly optional with NO default — consumers fail fast with the flag name when unset (no silent x.ai egress). Mock harnesses inject it explicitly. - System-prompt identity fixed: 'released by xAI' → 'an unofficial community CLI for Kimi' (template + regenerated encrypted form). Also repaired pre-existing grok-era test debt found by the sweep: the stale trace_classify default-model pin, the grok-pager UA label test, pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY test could previously reach the real api.moonshot.cn), and the outdated oauth fixture scope key. Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings); FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed; deny advisories ok.
262 lines
10 KiB
Rust
262 lines
10 KiB
Rust
//! Render [Mermaid](https://mermaid.js.org/) diagram source to a rasterized PNG,
|
||
//! behind a swappable [`MermaidEngine`] trait.
|
||
//!
|
||
//! This crate is a self-contained, pure-library building block: it turns Mermaid
|
||
//! diagram text into PNG bytes with no Node, no headless browser, and no network.
|
||
//! It isolates the layout engine and the SVG raster stack behind our own audited
|
||
//! boundary so the rest of the CLI can swap engines or fall back to a code block
|
||
//! without caring how a diagram is produced.
|
||
//!
|
||
//! # Pipeline
|
||
//!
|
||
//! 1. A [`MermaidEngine`] turns Mermaid source into an SVG and rasterizes it.
|
||
//! The default engine ([`PureRustEngine`]) uses the vendored, dagre-based
|
||
//! `mermaid-to-svg` for layout, then [`rasterize`].
|
||
//! 2. [`rasterize`] converts SVG to PNG with `resvg`/`usvg`/`tiny-skia`,
|
||
//! configured with **no remote/file resolvers** and a **bundled font** so it
|
||
//! is safe over untrusted input and deterministic across machines.
|
||
//!
|
||
//! # Untrusted input and crash isolation
|
||
//!
|
||
//! Because Mermaid source is untrusted model/tool output, call
|
||
//! [`render_checked`] rather than [`MermaidEngine::render`] directly: it enforces
|
||
//! a source-size limit and converts an engine panic into a [`MermaidError`].
|
||
//! `catch_unwind` only intercepts panics under `panic = "unwind"`; the shipped
|
||
//! CLI profiles build with `panic = "abort"`, where a panicking engine aborts
|
||
//! the process. The real crash isolation is therefore **out of process**: the
|
||
//! pager renders each diagram in a short-lived child process (see
|
||
//! [`run_with_timeout`] and the pager's `mermaid_worker`), so a panic or runaway
|
||
//! render is contained to the child and the wall-clock timeout is a real,
|
||
//! killable process kill. This crate provides both the in-process engine and the
|
||
//! subprocess spawn/timeout/reap building blocks that child uses.
|
||
//!
|
||
//! # Example
|
||
//!
|
||
//! ```
|
||
//! use kigi_mermaid::{default_engine, render_checked, RenderLimits, RenderParams};
|
||
//!
|
||
//! let engine = default_engine();
|
||
//! let params = RenderParams::default();
|
||
//! let result = render_checked(engine.as_ref(), "flowchart LR\nA-->B", ¶ms, &RenderLimits::default());
|
||
//! let diagram = result.expect("a simple flowchart renders");
|
||
//! assert!(diagram.width_px > 0 && diagram.height_px > 0);
|
||
//! ```
|
||
|
||
#![warn(missing_docs)]
|
||
|
||
mod engine;
|
||
mod mmdc;
|
||
mod pure;
|
||
mod raster;
|
||
mod subprocess;
|
||
|
||
pub use engine::{MermaidEngine, MermaidError, RenderLimits, render_checked};
|
||
pub use mmdc::{MmdcEngine, detect_mmdc};
|
||
pub use pure::PureRustEngine;
|
||
pub use raster::{MAX_OUTPUT_MEGAPIXELS, rasterize};
|
||
pub use subprocess::{SubprocessError, run_with_timeout};
|
||
|
||
use std::sync::Arc;
|
||
|
||
/// Which color scheme a diagram should be rendered for.
|
||
///
|
||
/// Mapped from the pager's theme by the caller; only the light/dark split is
|
||
/// relevant to diagram rendering.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||
pub enum MermaidTheme {
|
||
/// Light surfaces with dark text (e.g. `KigiDay`).
|
||
#[default]
|
||
Light,
|
||
/// Dark surfaces with light text (e.g. `KigiNight`, `TokyoNight`).
|
||
Dark,
|
||
}
|
||
|
||
/// Default opaque surface colors. Single source of truth, shared by the raster
|
||
/// background ([`MermaidTheme::surface_background`]) and the vendored engine's
|
||
/// theme background (`pure::theme_for`, via [`Rgba::to_hex`]).
|
||
pub(crate) const LIGHT_SURFACE: Rgba = Rgba::new(0xFA, 0xFA, 0xFA, 0xFF);
|
||
pub(crate) const DARK_SURFACE: Rgba = Rgba::new(0x18, 0x18, 0x1B, 0xFF);
|
||
|
||
impl MermaidTheme {
|
||
/// The default opaque surface color a diagram blends into for this theme.
|
||
///
|
||
/// Used as the raster background when the caller does not supply an explicit
|
||
/// [`RenderParams::background`]; chosen to approximate a typical terminal
|
||
/// scrollback surface so the PNG sits flush with the grid.
|
||
pub fn surface_background(self) -> Rgba {
|
||
match self {
|
||
MermaidTheme::Light => LIGHT_SURFACE,
|
||
MermaidTheme::Dark => DARK_SURFACE,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A straight 8-bit-per-channel, non-premultiplied RGBA color.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct Rgba {
|
||
/// Red channel, 0–255.
|
||
pub r: u8,
|
||
/// Green channel, 0–255.
|
||
pub g: u8,
|
||
/// Blue channel, 0–255.
|
||
pub b: u8,
|
||
/// Alpha channel, 0 (transparent) – 255 (opaque).
|
||
pub a: u8,
|
||
}
|
||
|
||
impl Rgba {
|
||
/// Construct an [`Rgba`] from its four channels.
|
||
pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
|
||
Self { r, g, b, a }
|
||
}
|
||
|
||
/// Format as an opaque `#RRGGBB` hex string (alpha is ignored).
|
||
pub fn to_hex(self) -> String {
|
||
format!("#{:02X}{:02X}{:02X}", self.r, self.g, self.b)
|
||
}
|
||
}
|
||
|
||
/// Parameters controlling a single diagram render.
|
||
///
|
||
/// Mirrors the sizing model: [`target_width_px`](Self::target_width_px)
|
||
/// is the primary size driver (already HiDPI-oversampled by the caller),
|
||
/// [`max_height_px`](Self::max_height_px) clamps tall diagrams, and
|
||
/// [`scale`](Self::scale) is the fallback oversample used only when
|
||
/// `target_width_px == 0`. [`min_width_px`](Self::min_width_px) raises the scale
|
||
/// so small diagrams still rasterize wide enough for OS viewers. The default
|
||
/// config is **target-width-driven** (`target_width_px` non-zero), so the default
|
||
/// `scale` is inert.
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub struct RenderParams {
|
||
/// Color scheme to render for.
|
||
pub theme: MermaidTheme,
|
||
/// Target output width in pixels. When non-zero this drives the output size
|
||
/// (the SVG is scaled so its width matches). `0` falls back to [`scale`](Self::scale).
|
||
pub target_width_px: u32,
|
||
/// Hard ceiling on output height in pixels; the render is scaled down to fit.
|
||
/// `0` disables the height clamp (output area is still bounded by
|
||
/// [`MAX_OUTPUT_MEGAPIXELS`]).
|
||
pub max_height_px: u32,
|
||
/// Oversample factor applied **only** when `target_width_px == 0`; inert
|
||
/// otherwise (the default config is target-width-driven, see the struct doc).
|
||
pub scale: f32,
|
||
/// Minimum output width in pixels. When non-zero, scale is raised so the
|
||
/// raster is at least this wide (before height / megapixel clamps). Useful
|
||
/// for OS-viewer opens of small diagrams. `0` disables.
|
||
pub min_width_px: u32,
|
||
/// Opaque background fill. `None` renders on a transparent background so the
|
||
/// terminal cell color shows through.
|
||
pub background: Option<Rgba>,
|
||
}
|
||
|
||
impl Default for RenderParams {
|
||
fn default() -> Self {
|
||
Self {
|
||
theme: MermaidTheme::Light,
|
||
target_width_px: 1024,
|
||
max_height_px: 4096,
|
||
// Inert by default (target_width_px drives sizing); 1.0 so a caller
|
||
// that zeroes target_width_px without touching scale gets 1:1.
|
||
scale: 1.0,
|
||
min_width_px: 0,
|
||
background: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl RenderParams {
|
||
/// Sizing tuned for opening a PNG in an OS image viewer: prefer 2× the SVG's
|
||
/// intrinsic size, ensure at least `min_width_px` width for small diagrams,
|
||
/// and allow a taller canvas than the terminal-budget path. Height and the
|
||
/// crate-wide megapixel/axis caps still apply.
|
||
pub fn for_os_viewer(theme: MermaidTheme, min_width_px: u32, max_height_px: u32) -> Self {
|
||
Self {
|
||
theme,
|
||
// Drive from `scale` + `min_width_px` so large SVGs keep aspect at 2×
|
||
// and small SVGs are upscaled to a readable minimum width.
|
||
target_width_px: 0,
|
||
max_height_px,
|
||
scale: 2.0,
|
||
min_width_px,
|
||
background: Some(theme.surface_background()),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A rendered diagram: PNG bytes plus the exact raster dimensions.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct RenderedDiagram {
|
||
/// The encoded PNG image.
|
||
pub png: Vec<u8>,
|
||
/// Output width in pixels.
|
||
pub width_px: u32,
|
||
/// Output height in pixels.
|
||
pub height_px: u32,
|
||
}
|
||
|
||
/// Construct the default engine: the offline, pure-Rust [`PureRustEngine`].
|
||
///
|
||
/// `mmdc` is never selected automatically — construct [`MmdcEngine`] explicitly
|
||
/// to opt in.
|
||
pub fn default_engine() -> Arc<dyn MermaidEngine> {
|
||
Arc::new(PureRustEngine::new())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn theme_surface_background_differs_light_vs_dark() {
|
||
let light = MermaidTheme::Light.surface_background();
|
||
let dark = MermaidTheme::Dark.surface_background();
|
||
assert_ne!(light, dark, "light and dark must map to different surfaces");
|
||
// Light surface is brighter than dark on every channel; both opaque.
|
||
assert!(light.r > dark.r && light.g > dark.g && light.b > dark.b);
|
||
assert_eq!(light.a, 0xFF);
|
||
assert_eq!(dark.a, 0xFF);
|
||
}
|
||
|
||
#[test]
|
||
fn rgba_to_hex_is_opaque_rrggbb() {
|
||
assert_eq!(Rgba::new(0x12, 0xAB, 0xCD, 0xFF).to_hex(), "#12ABCD");
|
||
// Alpha is ignored.
|
||
assert_eq!(Rgba::new(0, 0, 0, 0).to_hex(), "#000000");
|
||
// The shared dark surface const renders to the hex the dark theme uses.
|
||
assert_eq!(DARK_SURFACE.to_hex(), "#18181B");
|
||
}
|
||
|
||
#[test]
|
||
fn default_params_are_target_width_driven() {
|
||
// Exercises the real default path: target_width_px (1024) drives output
|
||
// width regardless of `scale`. Engine-agnostic, runs in the default build.
|
||
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50" viewBox="0 0 100 50"><rect width="10" height="10" fill="#0000ff"/></svg>"##;
|
||
let out = rasterize(svg, &RenderParams::default()).expect("render");
|
||
assert_eq!(
|
||
out.width_px, 1024,
|
||
"default target_width_px should drive width"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn default_engine_is_constructible_and_send_sync() {
|
||
fn assert_send_sync<T: Send + Sync>(_: &T) {}
|
||
let engine = default_engine();
|
||
assert_send_sync(&engine);
|
||
}
|
||
|
||
/// The default engine renders a real PNG and never panics on valid input.
|
||
#[test]
|
||
fn default_engine_renders_valid_input() {
|
||
let engine = default_engine();
|
||
let diagram = render_checked(
|
||
engine.as_ref(),
|
||
"flowchart LR\nA-->B",
|
||
&RenderParams::default(),
|
||
&RenderLimits::default(),
|
||
)
|
||
.expect("the default engine should render");
|
||
assert!(diagram.width_px > 0 && diagram.height_px > 0);
|
||
}
|
||
}
|