Skip to content

Commit 3e458ae

Browse files
text: rel attribute for relative text positioning
1 parent 2c89ca7 commit 3e458ae

4 files changed

Lines changed: 144 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
- Added: `<text>` elements can now have a `rel` attribute pointing to another
11+
element which provides the bounding box for the new text element, and as the
12+
basis for attributes such as `text-loc`. This feature makes it easier to
13+
position multiple text elements relative to an element.
14+
1015
## [0.27.0 - 2026-02-06]
1116

1217
- Changed: `<line>` based connectors are now axis-aligned by default where

src/elements/element.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,7 @@ impl SvgElement {
438438
let phantom = matches!(self.name(), "point" | "box");
439439

440440
if self.has_attr("text") {
441-
let (orig_elem, text_elements) = process_text_attr(self)?;
441+
let (orig_elem, text_elements) = process_text_attr(self, ctx)?;
442442

443443
if orig_elem.name != "text" && !phantom {
444444
// We only care about the original element if it wasn't a text element

src/elements/text.rs

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use super::SvgElement;
2+
use crate::context::ElementMap;
23
use crate::geometry::LocSpec;
3-
use crate::types::{attr_split_cycle, fstr, strp};
4+
use crate::types::{attr_split_cycle, fstr, strp, ElRef};
45

56
use crate::errors::{Error, Result};
67

@@ -36,7 +37,10 @@ fn text_string(text_value: &str) -> String {
3637
result
3738
}
3839

39-
fn get_text_position(element: &mut SvgElement) -> Result<(f32, f32, bool, LocSpec, Vec<String>)> {
40+
fn get_text_position(
41+
element: &mut SvgElement,
42+
ctx: &impl ElementMap,
43+
) -> Result<(f32, f32, bool, LocSpec, Vec<String>)> {
4044
let mut t_dx = 0.;
4145
let mut t_dy = 0.;
4246
{
@@ -60,6 +64,19 @@ fn get_text_position(element: &mut SvgElement) -> Result<(f32, f32, bool, LocSpe
6064
}
6165
}
6266

67+
// If a 'rel' attribute is present on a <text> element, resolve it to
68+
// determine the bounding box and element type (for inside/outside default).
69+
let mut text_ref_element = element.clone();
70+
if element.name() == "text" {
71+
if let Some(ref_str) = element.pop_attr("rel") {
72+
let elref: ElRef = ref_str.parse()?;
73+
text_ref_element = ctx
74+
.get_element(&elref)
75+
.ok_or_else(|| Error::Reference(elref))?
76+
.clone();
77+
}
78+
}
79+
6380
let mut text_classes = vec!["d-text".to_owned()];
6481
let text_loc_str = element.pop_attr("text-loc").unwrap_or("c".into());
6582
let text_anchor = text_loc_str.parse::<LocSpec>()?;
@@ -79,7 +96,7 @@ fn get_text_position(element: &mut SvgElement) -> Result<(f32, f32, bool, LocSpe
7996
} else if element.pop_class("d-text-inside") {
8097
false
8198
} else {
82-
matches!(element.name(), "line" | "point" | "text")
99+
matches!(text_ref_element.name(), "line" | "point" | "text")
83100
};
84101
match text_anchor {
85102
ls if ls.is_top() => {
@@ -140,7 +157,7 @@ fn get_text_position(element: &mut SvgElement) -> Result<(f32, f32, bool, LocSpe
140157
// Assumption is that text should be centered within the rect,
141158
// and has styling via CSS to reflect this, e.g.:
142159
// text.d-text { dominant-baseline: central; text-anchor: middle; }
143-
let (mut tdx, mut tdy) = element
160+
let (mut tdx, mut tdy) = text_ref_element
144161
.bbox()?
145162
.ok_or_else(|| Error::MissingBBox(element.to_string()))?
146163
.locspec(text_anchor);
@@ -150,7 +167,10 @@ fn get_text_position(element: &mut SvgElement) -> Result<(f32, f32, bool, LocSpe
150167
Ok((tdx, tdy, outside, text_anchor, text_classes))
151168
}
152169

153-
pub fn process_text_attr(element: &SvgElement) -> Result<(SvgElement, Vec<SvgElement>)> {
170+
pub fn process_text_attr(
171+
element: &SvgElement,
172+
ctx: &impl ElementMap,
173+
) -> Result<(SvgElement, Vec<SvgElement>)> {
154174
// Different conversions from line count to first-line offset based on whether
155175
// top, center, or bottom justification.
156176
const WRAP_DOWN: fn(usize, f32) -> f32 = |_count, _spacing| 0.;
@@ -164,7 +184,7 @@ pub fn process_text_attr(element: &SvgElement) -> Result<(SvgElement, Vec<SvgEle
164184

165185
let text_value = get_text_value(&mut orig_elem);
166186

167-
let (tdx, tdy, outside, text_loc, mut text_classes) = get_text_position(&mut orig_elem)?;
187+
let (tdx, tdy, outside, text_loc, mut text_classes) = get_text_position(&mut orig_elem, ctx)?;
168188

169189
let x_str = fstr(tdx);
170190
let y_str = fstr(tdy);

tests/integration_tests/text_attr.rs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,3 +614,115 @@ fn test_multiline_outside() {
614614
expected.trim()
615615
);
616616
}
617+
618+
#[test]
619+
fn test_text_rel_centered() {
620+
// text element with rel should position text at center of referenced element
621+
let input = r##"
622+
<rect id="r" xy="10 20" wh="100 50"/>
623+
<text rel="#r" text="hello"/>
624+
"##;
625+
let expected = r##"
626+
<rect id="r" x="10" y="20" width="100" height="50"/>
627+
<text x="60" y="45" class="d-text">hello</text>
628+
"##;
629+
assert_eq!(
630+
transform_str_default(input).unwrap().trim(),
631+
expected.trim()
632+
);
633+
}
634+
635+
#[test]
636+
fn test_text_rel_with_text_loc() {
637+
// text-loc should position text at the top of the rect bbox;
638+
// since referenced element is a rect, text defaults to 'inside'
639+
let input = r##"
640+
<rect id="r" xy="10 20" wh="100 50"/>
641+
<text rel="#r" text="top" text-loc="t"/>
642+
"##;
643+
let expected = r##"
644+
<rect id="r" x="10" y="20" width="100" height="50"/>
645+
<text x="60" y="21" class="d-text d-text-top">top</text>
646+
"##;
647+
assert_eq!(
648+
transform_str_default(input).unwrap().trim(),
649+
expected.trim()
650+
);
651+
}
652+
653+
#[test]
654+
fn test_text_rel_outside() {
655+
// explicit d-text-outside class should push text outside the rect
656+
let input = r##"
657+
<rect id="r" xy="10 20" wh="100 50"/>
658+
<text rel="#r" text="above" text-loc="t" class="d-text-outside"/>
659+
"##;
660+
let expected = r##"
661+
<rect id="r" x="10" y="20" width="100" height="50"/>
662+
<text x="60" y="19" class="d-text d-text-bottom">above</text>
663+
"##;
664+
assert_eq!(
665+
transform_str_default(input).unwrap().trim(),
666+
expected.trim()
667+
);
668+
}
669+
670+
#[test]
671+
fn test_text_rel_line_outside_default() {
672+
// relative to line element should default to 'outside'
673+
let input = r##"
674+
<line id="l1" xy1="0 0" xy2="100 0"/>
675+
<text rel="#l1" text="label" text-loc="t"/>
676+
"##;
677+
let expected = r##"
678+
<line id="l1" x1="0" y1="0" x2="100" y2="0"/>
679+
<text x="50" y="-1" class="d-text d-text-bottom">label</text>
680+
"##;
681+
assert_eq!(
682+
transform_str_default(input).unwrap().trim(),
683+
expected.trim()
684+
);
685+
}
686+
687+
#[test]
688+
fn test_text_rel_prev_element() {
689+
// Use ^ (previous element) as rel
690+
let input = r##"
691+
<rect xy="10 20" wh="100 50"/>
692+
<text rel="^" text="prev"/>
693+
"##;
694+
let expected = r##"
695+
<rect x="10" y="20" width="100" height="50"/>
696+
<text x="60" y="45" class="d-text">prev</text>
697+
"##;
698+
assert_eq!(
699+
transform_str_default(input).unwrap().trim(),
700+
expected.trim()
701+
);
702+
}
703+
704+
#[test]
705+
fn test_text_rel_nonexistent() {
706+
// reference to nonexistent element should error
707+
let input = r##"
708+
<text rel="#nonexistent" text="fail"/>
709+
"##;
710+
assert!(transform_str_default(input).is_err());
711+
}
712+
713+
#[test]
714+
fn test_text_rel_bottom_right() {
715+
// Test bottom-right loc on text rel (inside default)
716+
let input = r##"
717+
<rect id="r" xy="0" wh="100 50"/>
718+
<text rel="#r" text="br" text-loc="br"/>
719+
"##;
720+
let expected = r##"
721+
<rect id="r" x="0" y="0" width="100" height="50"/>
722+
<text x="99" y="49" class="d-text d-text-bottom d-text-right">br</text>
723+
"##;
724+
assert_eq!(
725+
transform_str_default(input).unwrap().trim(),
726+
expected.trim()
727+
);
728+
}

0 commit comments

Comments
 (0)