Merge pull request #6 from storybored379/pr/svg-import-text-on-path

SVG import: <textPath> comes back as type on a path
This commit is contained in:
Brandon Thomas 2026-10-04 10:16:28 -04:00 committed by GitHub
commit cbf5dc99bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 44 additions and 3 deletions

View File

@ -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 <textPath> naming a <path> (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(),

View File

@ -22,7 +22,7 @@
//! * Masks and filters are ignored (reported as warnings); nested clip paths use the outer clip only.
//! * usvg only keeps `<text>` when fonts are loaded; we don't load a font database (too expensive and
//! unavailable on wasm), so `<text>` 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 `<textPath>`) and placed on top of their layer.
#![forbid(unsafe_code)]
mod export;

View File

@ -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] {