crates/laymesh-render/src/lib.rs 1//! SVG is shared by native and WASM. Native exports use resvg and krilla.2use base64::Engine;3use laymesh_core::{4 Diagnostic, Loc, Result,5 model::{Scene, escape, jnum, jstr},6};7use serde_json::{Value as Json, json};8fn number(n: f64) -> String {9 if n == 0. { "0".into() } else { n.to_string() }10}11fn n(v: &Json, k: &str, d: f64) -> String {12 number(jnum(v, k, d))13}14fn error(message: impl Into<String>) -> Diagnostic {15 Diagnostic::new("E_RENDER", message, "", Loc::default())16}17fn pair(v: &Json, default: [f64; 2]) -> [f64; 2] {18 [19 v[0].as_f64().unwrap_or(default[0]),20 v[1].as_f64().unwrap_or(default[1]),21 ]22}23fn paint(p: &Json, defs: &mut Vec<String>) -> String {24 if let Some(s) = p.as_str() {25 return escape(&laymesh_core::color::css(s));26 }27 if p.is_null() {28 return "none".into();29 }30 let id = format!("paint-{}", defs.len());31 let kind = jstr(p, "kind", "");32 match kind {33 "pattern" | "hatch" => {34 let size = jnum(p, "spacing", 2.).max(0.001);35 let sw = jnum(p, "lineWidth", jnum(p, "line_width", 0.2));36 let color = escape(&laymesh_core::color::css(jstr(p, "color", "#000000")));37 let bg = escape(&laymesh_core::color::css(jstr(p, "background", "none")));38 let mark = if jstr(p, "pattern", jstr(p, "style", "lines")) == "dots" {39 format!(40 "<circle cx='{}' cy='{}' r='{}' fill='{color}'/>",41 size / 2.,42 size / 2.,43 sw / 2.44 )45 } else {46 let mut d = format!(47 "M{} {}L{} {}M0 {}L{} 0M0 {}L{} 0",48 -size,49 size,50 size,51 -size,52 size,53 size,54 size * 2.,55 size * 2.56 );57 if jstr(p, "pattern", jstr(p, "style", "lines")) == "cross" {58 d += &format!(59 "M{} 0L{} {}M0 0L{} {}M0 {}L{} {}",60 -size,61 size,62 2. * size,63 size,64 size,65 -size,66 2. * size,67 size68 );69 }70 format!("<path d='{d}' fill='none' stroke='{color}' stroke-width='{sw}'/>")71 };72 defs.push(format!("<pattern id='{id}' patternUnits='userSpaceOnUse' width='{size}' height='{size}' patternTransform='rotate({})'><rect width='{size}' height='{size}' fill='{bg}'/>{mark}</pattern>",n(p,"angle",0.)));73 }74 "imagePaint" | "image_paint" | "image_fill" => {75 let aspect = if jstr(p, "fit", "contain") == "stretch" {76 "none"77 } else if p["fit"] == "cover" {78 "xMidYMid slice"79 } else {80 "xMidYMid meet"81 };82 defs.push(format!("<pattern id='{id}' width='1' height='1' patternContentUnits='objectBoundingBox'><svg width='1' height='1' viewBox='0 0 {} {}' preserveAspectRatio='{aspect}'><image width='{}' height='{}' href='data:{};base64,{}'/></svg></pattern>",n(p,"width",1.),n(p,"height",1.),n(p,"width",1.),n(p,"height",1.),escape(jstr(p,"mime","image/png")),jstr(p,"data","")));83 }84 _ => {85 let stops = p["stops"]86 .as_array()87 .into_iter()88 .flatten()89 .map(|s| {90 let at = s91 .get("at")92 .and_then(Json::as_f64)93 .or_else(|| s[0].as_f64())94 .unwrap_or(0.);95 let color = s96 .get("color")97 .and_then(Json::as_str)98 .or_else(|| s[1].as_str())99 .unwrap_or("#000000");100 let opacity = s101 .get("opacity")102 .and_then(Json::as_f64)103 .or_else(|| s[2].as_f64())104 .unwrap_or(1.);105 format!(106 "<stop offset='{}%' stop-color='{}' stop-opacity='{}'/>",107 number(at * 100.),108 escape(&laymesh_core::color::css(color)),109 number(opacity)110 )111 })112 .collect::<String>();113 if matches!(kind, "linearGradient" | "linear_gradient") {114 let a = pair(&p["start"], [0., 0.]);115 let b = pair(&p["end"], [1., 1.]);116 defs.push(format!("<linearGradient id='{id}' x1='{}%' y1='{}%' x2='{}%' y2='{}%'>{stops}</linearGradient>",a[0]*100.,a[1]*100.,b[0]*100.,b[1]*100.));117 } else {118 let c = pair(&p["center"], [0.5, 0.5]);119 defs.push(format!(120 "<radialGradient id='{id}' cx='{}%' cy='{}%' r='{}%'>{stops}</radialGradient>",121 c[0] * 100.,122 c[1] * 100.,123 jnum(p, "radius", 0.5) * 100.124 ));125 }126 }127 }128 format!("url(#{id})")129}130fn wrap(node: &Json, body: String) -> String {131 let (w, h) = (jnum(node, "width", 0.), jnum(node, "height", 0.));132 let (x, y) = (jnum(node, "x", 0.) + w / 2., jnum(node, "y", 0.) + h / 2.);133 format!(134 "<g data-id=\"{}\" opacity=\"{}\" transform=\"translate({} {}) rotate({}) translate({} {})\">{body}</g>",135 escape(jstr(node, "id", "")),136 n(node, "opacity", 1.),137 number(x),138 number(y),139 n(node, "rotation", 0.),140 number(-w / 2.),141 number(-h / 2.)142 )143}144fn item_svg(item: &Json, defs: &mut Vec<String>) -> Result<String> {145 let kind = jstr(item, "kind", "");146 Ok(match kind {147 "glyph" => format!(148 "<text x=\"{}\" y=\"{}\" font-family=\"{}\" font-size=\"{}\" font-weight=\"{}\" font-style=\"{}\" fill=\"{}\" xml:space=\"preserve\">{}</text>",149 n(item, "x", 0.),150 n(item, "baseline", 0.),151 escape(jstr(item, "fontFamily", "")),152 n(item, "fontSize", 3.),153 n(item, "fontWeight", 400.),154 if item["fontItalic"].as_bool().unwrap_or(false) {155 "italic"156 } else {157 "normal"158 },159 escape(&laymesh_core::color::css(jstr(item, "color", "#000000"))),160 escape(jstr(item, "content", ""))161 ),162 "box" => format!(163 "<rect data-missing-glyph=\"true\" x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{}\"/>",164 n(item, "x", 0.),165 n(item, "y", 0.),166 n(item, "width", 2.),167 n(item, "height", 3.),168 escape(&laymesh_core::color::css(jstr(item, "color", "#000000"))),169 n(item, "strokeWidth", 0.15)170 ),171 "rule" => {172 if item["dashed"].as_bool().unwrap_or(false) {173 format!(174 "<path d='M{} {}h{}' stroke='{}' stroke-width='{}' stroke-dasharray='{} {}' opacity='{}'/>",175 n(item, "x", 0.),176 n(item, "y", 0.),177 n(item, "width", 0.),178 escape(&laymesh_core::color::css(jstr(item, "color", "#000000"))),179 n(item, "height", 0.1),180 jnum(item, "height", 0.1) * 3.,181 jnum(item, "height", 0.1) * 2.,182 n(item, "opacity", 1.)183 )184 } else {185 format!(186 "<rect x='{}' y='{}' width='{}' height='{}' fill='{}' opacity='{}'/>",187 n(item, "x", 0.),188 n(item, "y", 0.),189 n(item, "width", 0.),190 n(item, "height", 0.),191 escape(&laymesh_core::color::css(jstr(item, "color", "#000000"))),192 n(item, "opacity", 1.)193 )194 }195 }196 "path" if item.get("strokeStyle").is_none() => format!(197 "<path d=\"{}\" fill=\"{}\" stroke=\"{}\" stroke-width=\"{}\" opacity=\"{}\"/>",198 escape(jstr(item, "d", "")),199 escape(&laymesh_core::color::css(jstr(item, "fill", "#000000"))),200 escape(&laymesh_core::color::css(jstr(item, "stroke", "none"))),201 n(item, "strokeWidth", 0.),202 n(item, "opacity", 1.)203 ),204 "formula" => formula_svg(item, defs)?,205 _ => node_svg(item, defs)?,206 })207}208fn formula_svg(node: &Json, defs: &mut Vec<String>) -> Result<String> {209 let mut body = String::new();210 for item in node["items"].as_array().into_iter().flatten() {211 body += &item_svg(item, defs)?;212 }213 let source = escape(jstr(node, "source", ""));214 Ok(format!(215 "<g transform=\"translate({} {})\" data-latex-source=\"{source}\"><title>{source}</title>{body}</g>",216 n(node, "x", 0.),217 n(node, "y", 0.)218 ))219}220fn stroke_attrs(s: &Json) -> String {221 let dash = s["dash"]222 .as_array()223 .into_iter()224 .flatten()225 .filter_map(Json::as_f64)226 .map(number)227 .collect::<Vec<_>>()228 .join(" ");229 format!(230 "stroke=\"{}\" stroke-width=\"{}\" stroke-opacity=\"{}\" stroke-linecap=\"{}\" stroke-linejoin=\"{}\" stroke-miterlimit=\"{}\"{}",231 escape(&laymesh_core::color::css(jstr(s, "color", "none"))),232 n(s, "width", 0.),233 n(s, "opacity", 1.),234 escape(jstr(s, "cap", "butt")),235 escape(jstr(s, "join", "miter")),236 n(s, "miterLimit", 4.),237 if dash.is_empty() {238 String::new()239 } else {240 format!(241 " stroke-dasharray=\"{dash}\" stroke-dashoffset=\"{}\"",242 n(s, "dashOffset", 0.)243 )244 }245 )246}247fn node_svg(node: &Json, defs: &mut Vec<String>) -> Result<String> {248 node_svg_transformed(node, defs, kurbo::Affine::IDENTITY)249}250fn node_svg_transformed(251 node: &Json,252 defs: &mut Vec<String>,253 parent: kurbo::Affine,254) -> Result<String> {255 let transform = parent * laymesh_core::geometry::node_transform(node);256 if node["kind"] == "image" && node["mime"] == "image/png" && node["crop"].is_object() {257 let c = &node["crop"];258 let asset = laymesh_core::assets::crop_raster(259 jstr(node, "data", ""),260 [261 jnum(c, "x", 0.),262 jnum(c, "y", 0.),263 jnum(c, "width", 0.),264 jnum(c, "height", 0.),265 ],266 "",267 Loc::default(),268 )?;269 let mut node = node.clone();270 node["crop"] = Json::Null;271 node["data"] = asset["data"].clone();272 node["intrinsicWidth"] = asset["width"].clone();273 node["intrinsicHeight"] = asset["height"].clone();274 return node_svg_transformed(&node, defs, parent);275 }276 // Clipping contours need the same final physical precision as their ink.277 // A parent group may magnify an already laid out polar plot.278 if node["clipPath"]["geometryRecipe"].is_object() {279 let mut node = node.clone();280 if transform.determinant().abs() > 1e-30 {281 let physical = laymesh_core::geometry::resolved_path(&node["clipPath"], transform);282 node["clipPath"]["d"] = json!((transform.inverse() * physical).to_svg());283 }284 node["clipPath"]285 .as_object_mut()286 .unwrap()287 .remove("geometryRecipe");288 return node_svg_transformed(&node, defs, parent);289 }290 if node["kind"] == "path" && node["geometryRecipe"].is_object() {291 let sx = if jnum(node, "intrinsicWidth", 0.) > 0. {292 jnum(node, "width", 1.) / jnum(node, "intrinsicWidth", 1.)293 } else {294 1.295 };296 let sy = if jnum(node, "intrinsicHeight", 0.) > 0. {297 jnum(node, "height", 1.) / jnum(node, "intrinsicHeight", 1.)298 } else {299 1.300 };301 let final_transform = transform * kurbo::Affine::scale_non_uniform(sx, sy);302 let mut node = node.clone();303 if final_transform.determinant().abs() > 1e-30 {304 let physical = laymesh_core::geometry::resolved_path(&node, final_transform);305 node["d"] = json!((final_transform.inverse() * physical).to_svg());306 }307 node.as_object_mut().unwrap().remove("geometryRecipe");308 return node_svg_transformed(&node, defs, parent);309 }310 if node["kind"] == "path"311 && node["outlineD"].is_null()312 && matches!(313 jstr(&node["strokeStyle"], "compound", "single"),314 "double" | "triple"315 )316 {317 let path = kurbo::BezPath::from_svg(jstr(node, "d", "")).map_err(|_| error("无效路径"))?;318 let mut node = node.clone();319 let sx = if jnum(&node, "intrinsicWidth", 0.) > 0. {320 jnum(&node, "width", 1.) / jnum(&node, "intrinsicWidth", 1.)321 } else {322 1.323 };324 let sy = if jnum(&node, "intrinsicHeight", 0.) > 0. {325 jnum(&node, "height", 1.) / jnum(&node, "intrinsicHeight", 1.)326 } else {327 1.328 };329 let final_transform = transform * kurbo::Affine::scale_non_uniform(sx, sy);330 if final_transform.determinant().abs() > 1e-30 {331 let physical = laymesh_core::geometry::compound_outline_transformed(332 &path,333 &node["strokeStyle"],334 final_transform,335 );336 node["outlineD"] = json!((final_transform.inverse() * physical).to_svg());337 } else {338 node["outlineD"] = json!("");339 }340 return node_svg_transformed(&node, defs, parent);341 }342 let kind = jstr(node, "kind", "");343 let (w, h) = (jnum(node, "width", 0.), jnum(node, "height", 0.));344 let body = match kind {345 "group" => {346 let mut clip = String::new();347 if node["clipPath"].is_object() || node["clip"].is_object() {348 let id = format!("clip-{}", defs.len());349 let c = if node["clipPath"].is_object() {350 format!(351 "<path d=\"{}\" clip-rule=\"{}\"/>",352 escape(jstr(&node["clipPath"], "d", "")),353 escape(jstr(&node["clipPath"], "fillRule", "nonzero"))354 )355 } else {356 let c = &node["clip"];357 format!(358 "<rect x='{}' y='{}' width='{}' height='{}'/>",359 n(c, "x", 0.),360 n(c, "y", 0.),361 n(c, "width", w),362 n(c, "height", h)363 )364 };365 defs.push(format!(366 "<clipPath id='{id}' clipPathUnits='userSpaceOnUse'>{c}</clipPath>"367 ));368 clip = format!(" clip-path='url(#{id})'");369 }370 let sx = if jnum(node, "contentWidth", 0.) > 0. {371 w / jnum(node, "contentWidth", w)372 } else {373 1.374 };375 let sy = if jnum(node, "contentHeight", 0.) > 0. {376 h / jnum(node, "contentHeight", h)377 } else {378 1.379 };380 let mut children = String::new();381 for c in node["children"].as_array().into_iter().flatten() {382 children += &node_svg_transformed(383 c,384 defs,385 transform * kurbo::Affine::scale_non_uniform(sx, sy),386 )?;387 }388 format!(389 "<g transform='scale({} {})'><g{clip}>{children}</g></g>",390 number(sx),391 number(sy)392 )393 }394 "text" => {395 let mut runs = String::new();396 for r in node["runs"].as_array().into_iter().flatten() {397 runs += &item_svg(r, defs)?;398 }399 runs400 }401 "formula" => {402 let mut local = node.clone();403 local["x"] = json!(0);404 local["y"] = json!(0);405 formula_svg(&local, defs)?406 }407 "path" => {408 if node["endpointRecipe"].is_object() {409 let layers = laymesh_core::endpoints::layers(node, transform).map_err(error)?;410 let inverse = transform.inverse();411 let body = layers412 .into_iter()413 .map(|layer| {414 format!(415 "<path d=\"{}\" fill=\"{}\" fill-rule=\"nonzero\" opacity=\"{}\"/>",416 escape(&(inverse * layer.path).to_svg()),417 escape(&layer.color),418 number(layer.opacity)419 )420 })421 .collect::<String>();422 return Ok(wrap(node, body));423 }424 let fill = paint(&node["fill"], defs);425 let attrs = stroke_attrs(&node["strokeStyle"]);426 let d = escape(jstr(node, "d", ""));427 let rule = escape(jstr(node, "fillRule", "nonzero"));428 let sx = if jnum(node, "intrinsicWidth", 0.) > 0. {429 w / jnum(node, "intrinsicWidth", w)430 } else {431 1.432 };433 let sy = if jnum(node, "intrinsicHeight", 0.) > 0. {434 h / jnum(node, "intrinsicHeight", h)435 } else {436 1.437 };438 let path = if let Some(outline) = node["outlineD"].as_str() {439 format!(440 "<path d=\"{d}\" fill=\"{fill}\" fill-rule=\"{rule}\"/><path d=\"{}\" fill=\"{}\" fill-rule=\"evenodd\" fill-opacity=\"{}\"/>",441 escape(outline),442 escape(&laymesh_core::color::css(jstr(443 &node["strokeStyle"],444 "color",445 "#000000"446 ))),447 n(&node["strokeStyle"], "opacity", 1.)448 )449 } else {450 format!("<path d=\"{d}\" fill=\"{fill}\" fill-rule=\"{rule}\" {attrs}/>")451 };452 format!(453 "<g transform='scale({} {})'>{path}</g>",454 number(sx),455 number(sy)456 )457 }458 "rect" => format!(459 "<rect width='{w}' height='{h}' rx='{}' fill='{}' stroke='{}' stroke-width='{}'/>",460 n(node, "radius", 0.),461 paint(&node["fill"], defs),462 escape(&laymesh_core::color::css(jstr(node, "stroke", "none"))),463 n(node, "strokeWidth", 0.)464 ),465 "ellipse" => format!(466 "<ellipse cx='{}' cy='{}' rx='{}' ry='{}' fill='{}' stroke='{}' stroke-width='{}'/>",467 w / 2.,468 h / 2.,469 w / 2.,470 h / 2.,471 paint(&node["fill"], defs),472 escape(&laymesh_core::color::css(jstr(node, "stroke", "none"))),473 n(node, "strokeWidth", 0.)474 ),475 "image" => {476 let iw = jnum(node, "intrinsicWidth", w).max(1e-12);477 let ih = jnum(node, "intrinsicHeight", h).max(1e-12);478 let fit = jstr(node, "fit", "contain");479 let crop = &node["crop"];480 let (cx, cy, cw, ch) = if crop.is_object() {481 (482 jnum(crop, "x", 0.),483 jnum(crop, "y", 0.),484 jnum(crop, "width", iw),485 jnum(crop, "height", ih),486 )487 } else {488 (0., 0., iw, ih)489 };490 let scale = if fit == "cover" {491 (w / cw).max(h / ch)492 } else {493 (w / cw).min(h / ch)494 };495 let (sw, sh) = if fit == "stretch" {496 (w / cw, h / ch)497 } else {498 (scale, scale)499 };500 let (x, y) = ((w - cw * sw) / 2. - cx * sw, (h - ch * sh) / 2. - cy * sh);501 let image = format!(502 "<image x='{}' y='{}' width='{}' height='{}' href='data:{};base64,{}' preserveAspectRatio='none'{} />",503 number(x),504 number(y),505 number(iw * sw),506 number(ih * sh),507 escape(jstr(node, "mime", "image/png")),508 jstr(node, "data", ""),509 if jstr(node, "interpolation", "") == "nearest" {510 " image-rendering='optimizeSpeed'"511 } else {512 ""513 }514 );515 if fit == "cover" || crop.is_object() {516 let id = format!("clip-{}", defs.len());517 defs.push(format!(518 "<clipPath id='{id}'><rect width='{w}' height='{h}'/></clipPath>"519 ));520 format!("<g clip-path='url(#{id})'>{image}</g>")521 } else {522 image523 }524 }525 "points" => {526 let mut out = String::new();527 let positions = node["positions"].as_array().cloned().unwrap_or_default();528 for (i, xy) in positions.chunks_exact(2).enumerate() {529 let x = xy[0].as_f64().unwrap_or(0.);530 let y = xy[1].as_f64().unwrap_or(0.);531 let size = node["markerSizes"][i]532 .as_f64()533 .unwrap_or(jnum(node, "markerSize", 2.));534 // Batched values replace the scalar default: the scalar is the535 // first marker's value, not a second group opacity.536 let alpha =537 node["pointOpacities"][i]538 .as_f64()539 .unwrap_or(jnum(node, "pointOpacity", 1.));540 let fill = node["markerFills"][i].as_str().unwrap_or(jstr(541 node,542 "markerFill",543 jstr(node, "fill", jstr(node, "color", "#000000")),544 ));545 let stroke = jstr(node, "stroke", jstr(node, "markerStroke", "none"));546 let sw = if stroke == "none" {547 0.548 } else {549 jnum(node, "strokeWidth", jnum(node, "markerStrokeWidth", 0.))550 };551 let r = (size - sw).max(0.) / 2.;552 let attrs = format!(553 "fill=\"{}\" stroke=\"{}\" stroke-width=\"{sw}\" opacity=\"{alpha}\" stroke-linejoin=\"round\"",554 escape(fill),555 escape(stroke)556 );557 let marker = jstr(node, "marker", "circle");558 if marker == "circle" {559 out += &format!("<circle cx='{x}' cy='{y}' r='{r}' {attrs}/>");560 } else {561 let pts: Vec<[f64; 2]> = match marker {562 "square" => vec![[-r, -r], [r, -r], [r, r], [-r, r]],563 "diamond" => vec![[0., -r], [r, 0.], [0., r], [-r, 0.]],564 "triangle_down" => vec![[-r, -r], [r, -r], [0., r]],565 "triangle_left" => vec![[r, -r], [r, r], [-r, 0.]],566 "triangle_right" => vec![[-r, -r], [r, 0.], [-r, r]],567 "cross" | "plus" => {568 let t = r / 3.;569 vec![570 [-t, -r],571 [t, -r],572 [t, -t],573 [r, -t],574 [r, t],575 [t, t],576 [t, r],577 [-t, r],578 [-t, t],579 [-r, t],580 [-r, -t],581 [-t, -t],582 ]583 }584 "star" => (0..10)585 .map(|i| {586 let theta = (i as f64 * 36. - 90.).to_radians();587 let rr = if i % 2 == 0 { r } else { r * 0.4 };588 [rr * theta.cos(), rr * theta.sin()]589 })590 .collect(),591 _ => vec![[0., -r], [r, r], [-r, r]],592 };593 let d = pts594 .iter()595 .enumerate()596 .map(|(i, p)| {597 format!(598 "{}{} {}",599 if i == 0 { 'M' } else { 'L' },600 x + p[0],601 y + p[1]602 )603 })604 .collect::<String>();605 out += &format!("<path d='{d}Z' {attrs}/>");606 }607 }608 out609 }610 _ => return Err(error(format!("未知 Scene 对象:{kind}"))),611 };612 Ok(wrap(node, body))613}614fn used_fonts(scene: &Scene) -> std::collections::BTreeMap<String, (u16, bool)> {615 fn visit(node: &Json, fonts: &mut std::collections::BTreeMap<String, (u16, bool)>) {616 if node["kind"] == "glyph" {617 if let Some(key) = node["fontFamily"].as_str() {618 fonts.insert(619 key.into(),620 (621 jnum(node, "fontWeight", 400.) as u16,622 node["fontItalic"].as_bool().unwrap_or(false),623 ),624 );625 }626 }627 for key in ["children", "runs", "items"] {628 for child in node[key].as_array().into_iter().flatten() {629 visit(child, fonts);630 }631 }632 }633 let mut fonts = std::collections::BTreeMap::new();634 for node in &scene.nodes {635 visit(node, &mut fonts);636 }637 fonts638}639/// Render a portable SVG containing only the user fonts that the scene actually uses.640pub fn render_svg(scene: &Scene) -> Result<String> {641 svg(scene, true)642}643fn svg(scene: &Scene, embed_fonts: bool) -> Result<String> {644 if !scene.width.is_finite()645 || !scene.height.is_finite()646 || scene.width <= 0.647 || scene.height <= 0.648 {649 return Err(error("画布尺寸必须为正"));650 }651 let mut defs = vec![];652 let mut body = String::new();653 if !scene.background.is_null() && scene.background != "none" {654 body += &format!(655 "<rect width='{}' height='{}' fill='{}'/>",656 number(scene.width),657 number(scene.height),658 paint(&scene.background, &mut defs)659 );660 }661 for node in &scene.nodes {662 body += &node_svg(node, &mut defs)?;663 }664 let mut css = String::new();665 if embed_fonts {666 for (key, (weight, italic)) in used_fonts(scene) {667 let Some(font) = scene.fonts.get(&key) else {668 continue;669 };670 let mime = if font.data.starts_with(b"OTTO") {671 "font/otf"672 } else {673 "font/ttf"674 };675 css += &format!(676 "@font-face{{font-family:'{}';font-weight:{weight};font-style:{};src:url(data:{mime};base64,{})}}",677 escape(&key),678 if italic { "italic" } else { "normal" },679 base64::engine::general_purpose::STANDARD.encode(&font.data)680 );681 }682 }683 Ok(format!(684 "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"{}mm\" height=\"{}mm\" viewBox=\"0 0 {} {}\"><defs><style>{css}</style>{}</defs>{body}</svg>\n",685 number(scene.width),686 number(scene.height),687 number(scene.width),688 number(scene.height),689 defs.join("")690 ))691}692 693#[cfg(feature = "native")]694mod export;695#[cfg(feature = "native")]696mod native;697#[cfg(feature = "native")]698pub use export::{ExportOptions, export_format, render_export};699#[cfg(feature = "native")]700pub use native::{render_pdf, render_png};701 702#[cfg(test)]703mod tests {704 use super::*;705 fn empty() -> Scene {706 Scene {707 schema_version: 8,708 width: 25.4,709 height: 12.7,710 background: json!("none"),711 layout_dpi: 96.,712 canvas_unit: "mm".into(),713 export_dpi: 300.,714 nodes: vec![],715 warnings: vec![],716 fonts: Default::default(),717 }718 }719 #[test]720 fn physical_svg_dimensions() {721 let svg = render_svg(&empty()).unwrap();722 assert!(svg.contains("width=\"25.4mm\""));723 assert!(!svg.contains("data:font"));724 }725 #[test]726 fn missing_glyph_is_a_font_independent_path() {727 let mut scene = empty();728 let mut fs = laymesh_core::text::FontSystem::new(false);729 scene.nodes.push(730 laymesh_core::text::layout_text(731 &json!({"content":"中","font_size":4.}),732 None,733 &mut fs,734 &mut scene.warnings,735 "test.lay",736 Loc::default(),737 )738 .unwrap(),739 );740 let svg = render_svg(&scene).unwrap();741 assert!(svg.contains("data-missing-glyph"));742 assert!(!svg.contains("<text "));743 }744}