Skip to content

Commit a4436a8

Browse files
committed
Implement W3C SVG 2 compliant text-on-path placement
1 parent 5f4d01c commit a4436a8

1 file changed

Lines changed: 103 additions & 25 deletions

File tree

node-graph/nodes/text/src/text_on_path.rs

Lines changed: 103 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ use kurbo::{BezPath, ParamCurve, ParamCurveArclen, ParamCurveDeriv, PathEl, Path
55
use parley::PositionedLayoutItem;
66
use skrifa::MetadataProvider;
77
use skrifa::raw::FontRef as ReadFontsRef;
8-
use vector_types::Vector;
8+
use std::sync::Arc;
9+
use vector_types::{TextOnPathMetadata, Vector};
910

1011
#[derive(Debug, Default, Clone, Copy, PartialEq, Hash, serde::Serialize, serde::Deserialize, DynAny, node_macro::ChoiceType)]
1112
pub enum TextPathSide {
@@ -22,6 +23,27 @@ pub enum TextAnchor {
2223
End,
2324
}
2425

26+
#[derive(Debug, Default, Clone, Copy, PartialEq, Hash, serde::Serialize, serde::Deserialize, DynAny, node_macro::ChoiceType)]
27+
pub enum TextPathMethod {
28+
#[default]
29+
Align,
30+
Stretch,
31+
}
32+
33+
#[derive(Debug, Default, Clone, Copy, PartialEq, Hash, serde::Serialize, serde::Deserialize, DynAny, node_macro::ChoiceType)]
34+
pub enum TextPathSpacing {
35+
#[default]
36+
Exact,
37+
Auto,
38+
}
39+
40+
#[derive(Debug, Default, Clone, Copy, PartialEq, Hash, serde::Serialize, serde::Deserialize, DynAny, node_macro::ChoiceType)]
41+
pub enum LengthAdjust {
42+
#[default]
43+
Spacing,
44+
SpacingAndGlyphs,
45+
}
46+
2547
pub struct ArcLengthLut {
2648
lengths: Vec<f64>,
2749
params: Vec<(usize, f64)>,
@@ -174,26 +196,29 @@ fn maybe_reverse_path(path: BezPath, side: TextPathSide) -> BezPath {
174196
}
175197
}
176198

177-
fn is_glyph_hidden(mid: f64, start_offset: f64, total_length: f64, is_closed: bool, text_anchor: TextAnchor) -> bool {
178-
if !is_closed {
179-
return mid < 0.0 || mid > total_length;
180-
}
199+
fn is_glyph_hidden(mid: f64, start_offset: f64, total_length: f64, is_closed: bool, text_anchor: TextAnchor, rtl: bool) -> bool {
200+
if !is_closed { return mid < 0.0 || mid > total_length; }
181201
let d = mid - start_offset;
182-
match text_anchor {
202+
let effective_anchor = if rtl { match text_anchor { TextAnchor::Start => TextAnchor::End, TextAnchor::End => TextAnchor::Start, _ => text_anchor } } else { text_anchor };
203+
match effective_anchor {
183204
TextAnchor::Start => d < 0.0 || d > total_length,
184205
TextAnchor::Middle => d < -total_length / 2.0 || d > total_length / 2.0,
185206
TextAnchor::End => d < -total_length || d > 0.0,
186207
}
187208
}
188209

189210
fn resolve_startpoint(abs_offset: f64, total_advance: f64, text_anchor: TextAnchor) -> f64 {
190-
match text_anchor {
191-
TextAnchor::Start => abs_offset,
192-
TextAnchor::Middle => abs_offset - total_advance / 2.0,
193-
TextAnchor::End => abs_offset - total_advance,
194-
}
211+
match text_anchor { TextAnchor::Start => abs_offset, TextAnchor::Middle => abs_offset - total_advance / 2.0, TextAnchor::End => abs_offset - total_advance }
212+
}
213+
214+
fn curvature_spacing_adjustment(lut: &ArcLengthLut, mid: f64, advance: f64) -> f64 {
215+
let half = advance / 2.0;
216+
let (_, a0) = at_with_extension(lut, mid - half);
217+
let (_, a1) = at_with_extension(lut, mid + half);
218+
advance * (a1 - a0).abs() * 0.1
195219
}
196220

221+
#[allow(clippy::too_many_arguments)]
197222
pub fn place_text_on_path<Upstream: Default + 'static>(
198223
text: &str,
199224
path_table: &Table<Vector<Upstream>>,
@@ -204,17 +229,25 @@ pub fn place_text_on_path<Upstream: Default + 'static>(
204229
start_offset_percent: bool,
205230
side: TextPathSide,
206231
text_anchor: TextAnchor,
232+
method: TextPathMethod,
233+
spacing: TextPathSpacing,
234+
text_length: Option<f64>,
235+
length_adjust: LengthAdjust,
236+
path_length: Option<f64>,
237+
rtl: bool,
207238
font_cache: &crate::FontCache,
208239
) -> Table<Vector<Upstream>> {
209-
let Some(path_row) = path_table.iter().next() else { return Table::new() };
210-
let bezpath = path_row.element.stroke_bezpath_iter().find(|path| path.segments().next().is_some());
211-
let Some(bezpath) = bezpath else { return Table::new() };
240+
// TODO: Support method="stretch" (warp glyph outlines along path perpendiculars)
241+
if method == TextPathMethod::Stretch {
242+
log::warn!("textPath method='stretch' is not yet implemented; falling back to 'align'");
243+
}
244+
245+
let Some(original_bezpath) = path_table.iter().next().and_then(|row| row.element.stroke_bezpath_iter().find(|p| p.segments().next().is_some())) else { return Table::new() };
246+
let path_d_for_export = original_bezpath.to_svg();
212247

213-
let bezpath = maybe_reverse_path(bezpath, side);
248+
let bezpath = maybe_reverse_path(original_bezpath, side);
214249
let lut = ArcLengthLut::build(&bezpath, 100);
215-
if lut.total_length < 1e-9 {
216-
return Table::new();
217-
}
250+
if lut.total_length < 1e-9 { return Table::new(); }
218251

219252
let typesetting = crate::TypesettingConfig {
220253
font_size,
@@ -225,13 +258,30 @@ pub fn place_text_on_path<Upstream: Default + 'static>(
225258
let layout = crate::TextContext::with_thread_local(|ctx| ctx.layout_text(text, font, font_cache, typesetting));
226259
let Some(layout) = layout else { return Table::new() };
227260

228-
let abs_offset = if start_offset_percent { start_offset * lut.total_length } else { start_offset };
261+
let mut abs_offset = if start_offset_percent { start_offset * lut.total_length } else { start_offset };
262+
if let Some(author_length) = path_length.filter(|&l| l > 1e-9) { abs_offset *= lut.total_length / author_length; }
229263

230264
let mut path_builder = crate::path_builder::PathBuilder::new(true, layout.scale() as f64);
231265

232266
layout.lines().for_each(|line| {
233267
let line_width = line.metrics().advance as f64;
234-
let line_start = resolve_startpoint(abs_offset, line_width, text_anchor);
268+
269+
let glyph_count: usize = line.items().map(|item| if let PositionedLayoutItem::GlyphRun(gr) = item { gr.glyphs().count() } else { 0 }).sum();
270+
271+
let (advance_scale, spacing_delta) = if let Some(target) = text_length.filter(|&t| t > 0.0 && line_width > 1e-9) {
272+
match length_adjust {
273+
LengthAdjust::Spacing => (1.0, (target - line_width) / glyph_count.saturating_sub(1).max(1) as f64),
274+
LengthAdjust::SpacingAndGlyphs => (target / line_width, 0.0),
275+
}
276+
} else {
277+
(1.0, 0.0)
278+
};
279+
280+
let effective_line_width = line_width * advance_scale + spacing_delta * glyph_count.saturating_sub(1) as f64;
281+
let line_start = resolve_startpoint(abs_offset, effective_line_width, text_anchor);
282+
283+
let mut cumulative_offset = 0.0_f64;
284+
let mut glyph_index = 0_usize;
235285

236286
line.items().for_each(|item| {
237287
if let PositionedLayoutItem::GlyphRun(glyph_run) = item {
@@ -244,12 +294,16 @@ pub fn place_text_on_path<Upstream: Default + 'static>(
244294
let outlines = ReadFontsRef::from_index(font.data.as_ref(), font.index).unwrap().outline_glyphs();
245295

246296
glyph_run.glyphs().for_each(|glyph| {
247-
let glyph_path_pos = line_start + run_x as f64;
248-
let mid = glyph_path_pos + glyph.advance as f64 / 2.0;
297+
let raw_advance = glyph.advance as f64 * advance_scale;
298+
cumulative_offset += if glyph_index > 0 { spacing_delta } else { 0.0 };
299+
let mid = line_start + run_x as f64 * advance_scale + cumulative_offset - glyph_run.offset() as f64 * advance_scale + raw_advance / 2.0;
300+
let adjusted_mid = mid + if spacing == TextPathSpacing::Auto { curvature_spacing_adjustment(&lut, mid, raw_advance) } else { 0.0 };
301+
249302
run_x += glyph.advance;
303+
glyph_index += 1;
250304

251-
if !is_glyph_hidden(mid, abs_offset, lut.total_length, lut.is_closed, text_anchor) {
252-
let effective_mid = if lut.is_closed { mid.rem_euclid(lut.total_length) } else { mid };
305+
if !is_glyph_hidden(adjusted_mid, abs_offset, lut.total_length, lut.is_closed, text_anchor, rtl) {
306+
let effective_mid = if lut.is_closed { adjusted_mid.rem_euclid(lut.total_length) } else { adjusted_mid };
253307
let (point, angle) = if lut.is_closed { lut.at_or_zero(effective_mid) } else { at_with_extension(&lut, effective_mid) };
254308

255309
if let Some(glyph_outline) = outlines.get(skrifa::GlyphId::from(glyph.id)) {
@@ -259,10 +313,34 @@ pub fn place_text_on_path<Upstream: Default + 'static>(
259313
path_builder.draw_glyph(&glyph_outline, font_size, &normalized_coords, style_skew, final_transform, true);
260314
}
261315
}
316+
317+
cumulative_offset += raw_advance - glyph.advance as f64;
262318
});
263319
}
264320
});
265321
});
266322

267-
path_builder.finalize()
323+
let mut result = path_builder.finalize();
324+
325+
// Attach text-on-path metadata so SVG export can emit <text><textPath> instead of raw outlines
326+
let metadata = Arc::new(TextOnPathMetadata {
327+
text: text.to_string(),
328+
font_family: font.font_family.clone(),
329+
font_style: font.font_style.clone(),
330+
font_size,
331+
path_d: path_d_for_export,
332+
start_offset,
333+
start_offset_percent,
334+
text_anchor: match text_anchor { TextAnchor::Start => "start", TextAnchor::Middle => "middle", TextAnchor::End => "end" }.to_string(),
335+
side: match side { TextPathSide::Left => "left", TextPathSide::Right => "right" }.to_string(),
336+
method: match method { TextPathMethod::Align => "align", TextPathMethod::Stretch => "stretch" }.to_string(),
337+
spacing: match spacing { TextPathSpacing::Exact => "exact", TextPathSpacing::Auto => "auto" }.to_string(),
338+
text_length,
339+
length_adjust: match length_adjust { LengthAdjust::Spacing => "spacing", LengthAdjust::SpacingAndGlyphs => "spacingAndGlyphs" }.to_string(),
340+
});
341+
for row in result.iter_mut() {
342+
row.element.text_on_path_metadata = Some(Arc::clone(&metadata));
343+
}
344+
345+
result
268346
}

0 commit comments

Comments
 (0)