diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 8bc4dcee..c8bfe40a 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -685,13 +685,34 @@ fn text_fallback(im: &mut Importer, svg: &str, w: f64, h: f64, layer_mode: bool) if rb.runs.is_empty() { continue; } + // Type on a path: a naming a (in the text's user space) by href. + let on_path = t.children().find(|c| c.is_element() && c.tag_name().name() == "textPath").and_then(|tp| { + let href = tp.attribute("href").or_else(|| tp.attribute(("http://www.w3.org/1999/xlink", "href")))?; + let id = href.strip_prefix('#')?; + let el = xml.descendants().find(|n| n.is_element() && n.tag_name().name() == "path" && n.attribute("id") == Some(id))?; + let mut bp = BezPath::from_svg(el.attribute("d")?).ok()?; + bp.apply_affine(acc * parse_transform(el.attribute("transform"))); + let start = match tp.attribute("startOffset").map(str::trim) { + Some(o) if o.ends_with('%') => first_number(o.strip_suffix('%')) / 100.0, + Some(o) => { + let len: f64 = bp.segments().map(|s| kurbo::ParamCurveArclen::arclen(&s, 1e-3)).sum(); + if len > 0.0 { first_number(Some(o)) / len } else { 0.0 } + } + None => 0.0, + }; + Some((PathData::from_bezpath(&bp), start.clamp(0.0, 1.0))) + }); let justify = match ctx.prop(t, "text-anchor").as_deref() { Some("middle") => Justify::Center, Some("end") => Justify::Right, _ => Justify::Left, }; + let (kind, xf) = match on_path { + Some((path, start)) => (TextKind::OnPath { path, start }, Affine::IDENTITY), + None => (TextKind::Point, xf), + }; let mut obj = TextObject { - kind: TextKind::Point, + kind, xf, runs: rb.runs, para: Default::default(), diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 932ce101..737dae84 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -22,7 +22,7 @@ //! * Masks and filters are ignored (reported as warnings); nested clip paths use the outer clip only. //! * usvg only keeps `` when fonts are loaded; we don't load a font database (too expensive and //! unavailable on wasm), so `` elements are read directly from the XML as live point-type -//! [`TextObject`]s and placed on top of their layer. +//! [`TextObject`]s (type on a path for a ``) and placed on top of their layer. #![forbid(unsafe_code)] mod export; diff --git a/crates/svg/tests/roundtrip.rs b/crates/svg/tests/roundtrip.rs index f3159fe0..0719bcc4 100644 --- a/crates/svg/tests/roundtrip.rs +++ b/crates/svg/tests/roundtrip.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::{ Appearance, AppearanceItem, CharStyle, Dash, Document, FillLayer, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeAlign, - StrokeLayer, TextObject, + StrokeLayer, TextKind, TextObject, }; use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, shapes}; use vectorcraft_svg::{ExportOptions, Styling, export, import, import_with_report}; @@ -374,6 +374,26 @@ fn text_roundtrip_and_escaping() { assert!(t.xf.translation().to_point().distance(Point::new(20.0, 50.0)) < 0.01); } +#[test] +fn text_on_path_roundtrip() { + let mut d = Document::new(200.0, 200.0); + let mut curve = vectorcraft_geom::BezPath::new(); + curve.move_to((20.0, 150.0)); + curve.curve_to((60.0, 40.0), (140.0, 40.0), (180.0, 150.0)); + let mut t = TextObject::point(Point::new(0.0, 0.0), "On a curve", CharStyle { size: 14.0, ..CharStyle::default() }); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&curve), start: 0.25 }; + let id = d.alloc_id(); + let d = doc_with(vec![Node::new(id, NodeKind::Text(Box::new(t)))]); + let r = roundtrip(&d); + let a = art(&r); + let NodeKind::Text(t) = &a[0].kind else { panic!() }; + assert_eq!(t.plain_text(), "On a curve"); + let TextKind::OnPath { path, start } = &t.kind else { panic!("not type on a path: {:?}", t.kind) }; + assert!((start - 0.25).abs() < 1e-6, "start {start}"); + let doc_path = path.bounds().map(|b| t.xf.transform_rect_bbox(b)).unwrap(); + assert!(close_rect(doc_path, PathData::from_bezpath(&curve).bounds().unwrap(), 0.01), "{doc_path:?}"); +} + #[test] fn styling_modes_roundtrip() { for styling in [Styling::PresentationAttributes, Styling::InlineStyle, Styling::InternalCss] {