mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-04 19:15:33 +08:00
37ffef76a4
fix #14979 [Bug]: mermaid transparent text in light theme ## Summary Mermaid diagram preview in code blocks showed shapes and connectors but no node or edge labels, with poor contrast in dark mode. This change fixes rendering, sanitization, and display so labels are visible in both light and dark themes. ## Root cause 1. **Mermaid 11 config** — `flowchart.htmlLabels: false` is ignored; only root-level `htmlLabels` applies. Labels were still emitted in `<foreignObject>`. 2. **SVG sanitization** — `sanitizeSvg()` removed all `foreignObject` elements (and did not allow `<use>`), stripping most label content. 3. **Theme mismatch** — Preview always used Mermaid’s light `default` theme while the preview panel follows AFFiNE light/dark, causing dark text on dark backgrounds for edge and title text. 4. **Embedded CSS** — Mermaid’s inline SVG styles often do not apply after sanitization, leaving text without a visible `fill`. ## Changes ### Classic renderer (`classic-mermaid.ts`) - Set root-level `htmlLabels: false` (Mermaid 11+). - Map `dark` theme to Mermaid’s built-in `dark` palette. ### Sanitization (`bridge.ts`) - Allow `<use>` and `xlink:href` / `href` for label references. - Allow `class`, `style`, and `id` on SVG nodes. - **Sanitize** `foreignObject` inner HTML with DOMPurify instead of deleting it. ### Preview UI (`mermaid-preview.ts`) - Sync render theme with app `data-theme` (`default` / `dark`) and re-render on theme change. - Add CSS overrides so `text` / `tspan` and HTML inside `foreignObject` use AFFiNE `text/primary`. ### Native / mobile (`preview.rs`) - Map `dark` and `modern` themes to the modern renderer options (light uses `default`). ### Types & tests - Extend `MermaidRenderTheme` with `'dark'`. - Update unit tests for sanitization and classic config. - Add integration test (skips when the test environment cannot lay out Mermaid). ## Test plan - [ ] Hard refresh or restart `yarn dev`. - [ ] Create a `mermaid` code block: `graph TD; A-->B` → enable **Preview**. - [ ] Confirm labels **A** and **B** appear inside nodes and on the edge. - [ ] Toggle AFFiNE **light** / **dark** theme; confirm preview updates and text stays readable. - [ ] Run unit tests: ```bash yarn vitest run packages/frontend/core/src/modules/code-block-preview-renderer/ ``` - [ ] (Optional) With **Enable Native Mermaid Renderer** enabled in experimental settings, repeat the manual check. ## Notes for reviewers - Security: `foreignObject` content is sanitized with the HTML profile; scripts are stripped. - The integration test intentionally skips when Mermaid produces an empty diagram (e.g. happy-dom without full browser layout). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Mermaid diagrams now adapt to the app's dark or light theme and update in real time. * **Improvements** * SVG sanitization now preserves diagram labels and foreignObject text while removing unsafe content. * Classic Mermaid rendering adjusted to keep text labels intact for previews. * **Tests** * Added unit and integration tests covering Mermaid rendering and SVG sanitization. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
193 lines
5.0 KiB
Rust
193 lines
5.0 KiB
Rust
use std::path::PathBuf;
|
|
|
|
use mermaid_rs_renderer::RenderOptions;
|
|
use napi::{Error, Result};
|
|
use napi_derive::napi;
|
|
use typst::layout::{Abs, PagedDocument};
|
|
use typst_as_lib::{TypstEngine, typst_kit_options::TypstKitFontOptions};
|
|
|
|
#[napi(object)]
|
|
pub struct MermaidRenderOptions {
|
|
pub theme: Option<String>,
|
|
pub font_family: Option<String>,
|
|
pub font_size: Option<f64>,
|
|
}
|
|
|
|
#[napi(object)]
|
|
pub struct MermaidRenderRequest {
|
|
pub code: String,
|
|
pub options: Option<MermaidRenderOptions>,
|
|
}
|
|
|
|
#[napi(object)]
|
|
pub struct MermaidRenderResult {
|
|
pub svg: String,
|
|
}
|
|
|
|
fn resolve_mermaid_render_options(options: Option<MermaidRenderOptions>) -> RenderOptions {
|
|
let mut render_options = match options.as_ref().and_then(|options| options.theme.as_deref()) {
|
|
Some("default") => RenderOptions::mermaid_default(),
|
|
Some("dark") | Some("modern") => RenderOptions::modern(),
|
|
_ => RenderOptions::modern(),
|
|
};
|
|
|
|
if let Some(options) = options {
|
|
if let Some(font_family) = options.font_family {
|
|
render_options.theme.font_family = font_family;
|
|
}
|
|
|
|
if let Some(font_size) = options.font_size {
|
|
render_options.theme.font_size = font_size as f32;
|
|
}
|
|
}
|
|
|
|
render_options
|
|
}
|
|
|
|
#[napi]
|
|
pub fn render_mermaid_svg(request: MermaidRenderRequest) -> Result<MermaidRenderResult> {
|
|
let render_options = resolve_mermaid_render_options(request.options);
|
|
let svg = mermaid_rs_renderer::render_with_options(&request.code, render_options)
|
|
.map_err(|error| Error::from_reason(error.to_string()))?;
|
|
|
|
Ok(MermaidRenderResult { svg })
|
|
}
|
|
|
|
#[napi(object)]
|
|
pub struct TypstRenderOptions {
|
|
pub font_urls: Option<Vec<String>>,
|
|
pub font_dirs: Option<Vec<String>>,
|
|
}
|
|
|
|
#[napi(object)]
|
|
pub struct TypstRenderRequest {
|
|
pub code: String,
|
|
pub options: Option<TypstRenderOptions>,
|
|
}
|
|
|
|
#[napi(object)]
|
|
pub struct TypstRenderResult {
|
|
pub svg: String,
|
|
}
|
|
|
|
fn resolve_local_font_dir(value: &str) -> Option<PathBuf> {
|
|
let path = if let Some(stripped) = value.strip_prefix("file://") {
|
|
PathBuf::from(stripped)
|
|
} else {
|
|
let path = PathBuf::from(value);
|
|
if !path.is_absolute() {
|
|
return None;
|
|
}
|
|
path
|
|
};
|
|
|
|
if path.is_dir() {
|
|
return Some(path);
|
|
}
|
|
|
|
path.parent().map(|parent| parent.to_path_buf())
|
|
}
|
|
|
|
fn resolve_typst_font_dirs(options: &Option<TypstRenderOptions>) -> Vec<PathBuf> {
|
|
let Some(options) = options.as_ref() else {
|
|
return Vec::new();
|
|
};
|
|
|
|
let mut font_dirs = options
|
|
.font_dirs
|
|
.as_ref()
|
|
.map(|dirs| dirs.iter().map(PathBuf::from).collect::<Vec<_>>())
|
|
.unwrap_or_default();
|
|
|
|
if let Some(font_urls) = options.font_urls.as_ref() {
|
|
font_dirs.extend(font_urls.iter().filter_map(|url| resolve_local_font_dir(url)));
|
|
}
|
|
|
|
font_dirs
|
|
}
|
|
|
|
fn normalize_typst_svg(svg: String) -> String {
|
|
let mut svg = svg;
|
|
let page_background_marker = r##"<path class="typst-shape""##;
|
|
let mut cursor = 0;
|
|
|
|
while let Some(relative_idx) = svg[cursor..].find(page_background_marker) {
|
|
let idx = cursor + relative_idx;
|
|
let rest = &svg[idx..];
|
|
let Some(relative_end) = rest.find("/>") else {
|
|
break;
|
|
};
|
|
|
|
let end = idx + relative_end + 2;
|
|
let path_fragment = &svg[idx..end];
|
|
let is_page_background_path =
|
|
path_fragment.contains(r#"d="M 0 0v "#) && path_fragment.contains(r#" h "#) && path_fragment.contains(r#" v -"#);
|
|
|
|
if is_page_background_path {
|
|
svg.replace_range(idx..end, "");
|
|
cursor = idx;
|
|
continue;
|
|
}
|
|
|
|
cursor = end;
|
|
}
|
|
|
|
svg
|
|
}
|
|
|
|
#[napi]
|
|
pub fn render_typst_svg(request: TypstRenderRequest) -> Result<TypstRenderResult> {
|
|
let font_dirs = resolve_typst_font_dirs(&request.options);
|
|
let search_options = TypstKitFontOptions::new()
|
|
.include_system_fonts(false)
|
|
.include_embedded_fonts(true)
|
|
.include_dirs(font_dirs);
|
|
|
|
let engine = TypstEngine::builder()
|
|
.main_file(request.code)
|
|
.search_fonts_with(search_options)
|
|
.with_package_file_resolver()
|
|
.build();
|
|
|
|
let document = engine
|
|
.compile::<PagedDocument>()
|
|
.output
|
|
.map_err(|error| Error::from_reason(error.to_string()))?;
|
|
|
|
let svg = normalize_typst_svg(typst_svg::svg_merged(&document, Abs::pt(0.0)));
|
|
Ok(TypstRenderResult { svg })
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::normalize_typst_svg;
|
|
|
|
#[test]
|
|
fn normalize_typst_svg_removes_all_backgrounds() {
|
|
let input = r##"<svg>
|
|
<path class="typst-shape" fill="#ffffff" fill-rule="nonzero" d="M 0 0v 10 h 10 v -10 Z "/>
|
|
<g></g>
|
|
<path class="typst-shape" fill="#ffffff" fill-rule="nonzero" d="M 0 0v 10 h 10 v -10 Z "/>
|
|
<g transform="matrix(1 0 0 1 0 10)"></g>
|
|
</svg>"##
|
|
.to_string();
|
|
|
|
let normalized = normalize_typst_svg(input);
|
|
let retained = normalized
|
|
.matches(r##"<path class="typst-shape" fill="#ffffff" fill-rule="nonzero""##)
|
|
.count();
|
|
assert_eq!(retained, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_typst_svg_keeps_non_background_paths() {
|
|
let input = r##"<svg>
|
|
<path class="typst-shape" fill="#000000" fill-rule="nonzero" d="M 1 2 L 3 4 Z "/>
|
|
</svg>"##
|
|
.to_string();
|
|
|
|
let normalized = normalize_typst_svg(input);
|
|
assert!(normalized.contains(r##"d="M 1 2 L 3 4 Z ""##));
|
|
}
|
|
}
|