Skip to main content

pathland_core/
attributed.rs

1//! Attributed text: a node's text plus range-scoped style runs and links.
2//!
3//! This is the payload carried by `PARAMETER::SET_ATTRIBUTED_TEXT` (see
4//! `spec/OPCODE.md`). The text is UTF-8; run/link ranges are UTF-8 **byte**
5//! offsets (half-open, `[start, end)`). The payload is arena-backed: the
6//! opcode references one length-prefixed entry whose bytes are produced by
7//! [`AttributedString::encode`].
8//!
9//! V1 style attributes are scalar text-format properties only:
10//! `FONT_SIZE`, `FONT_WEIGHT`, `COLOR`, `FONT_STYLE`, `FONT_DESIGN`,
11//! `UNDERLINE`, `STRIKETHROUGH`. Unknown properties/types are ignored on decode
12//! so a newer producer never breaks an older renderer.
13
14use alloc::string::String;
15use alloc::vec::Vec;
16
17use crate::{property_id, value_type};
18
19/// An error raised while building, encoding, or decoding attributed text.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum AttributedError {
22    /// A range's start is after its end.
23    InvalidRange,
24    /// A range extends past the text or splits a UTF-8 code point.
25    RangeOutOfBounds,
26    /// The text is longer than the wire's `u32` length field.
27    TextTooLong,
28    /// The payload is truncated or malformed.
29    Malformed,
30    /// The text (or a link URL) is not valid UTF-8.
31    InvalidUtf8,
32}
33
34impl core::fmt::Display for AttributedError {
35    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
36        f.write_str(match self {
37            AttributedError::InvalidRange => "invalid attributed-text range",
38            AttributedError::RangeOutOfBounds => "attributed-text range out of bounds",
39            AttributedError::TextTooLong => "attributed text exceeds the u32 wire length",
40            AttributedError::Malformed => "malformed attributed-text payload",
41            AttributedError::InvalidUtf8 => "attributed text is not valid UTF-8",
42        })
43    }
44}
45
46/// A single scalar text-format attribute on a range.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct TextAttribute {
49    /// The property id (a text-format id from [`property_id`]).
50    pub property: u16,
51    /// The standard wire value type (`F32`, `COLOR`, `U8`, …).
52    pub value_type: u8,
53    /// The property's 4-byte wire value (bit pattern for `F32`).
54    pub value: u32,
55}
56
57impl TextAttribute {
58    /// Construct an attribute from raw wire parts.
59    #[must_use]
60    pub const fn new(property: u16, value_type: u8, value: u32) -> Self {
61        Self {
62            property,
63            value_type,
64            value,
65        }
66    }
67
68    /// `FONT_SIZE` (points).
69    #[must_use]
70    pub fn font_size(points: f32) -> Self {
71        Self::new(property_id::FONT_SIZE, value_type::F32, points.to_bits())
72    }
73
74    /// `FONT_WEIGHT` (100–900).
75    #[must_use]
76    pub fn font_weight(weight: f32) -> Self {
77        Self::new(property_id::FONT_WEIGHT, value_type::F32, weight.to_bits())
78    }
79
80    /// `COLOR` (`0xAARRGGBB`).
81    #[must_use]
82    pub const fn color(argb: u32) -> Self {
83        Self::new(property_id::COLOR, value_type::COLOR, argb)
84    }
85
86    /// `FONT_STYLE = Italic`.
87    #[must_use]
88    pub fn italic() -> Self {
89        Self::new(property_id::FONT_STYLE, value_type::F32, 1.0f32.to_bits())
90    }
91
92    /// `FONT_STYLE = Normal`.
93    #[must_use]
94    pub fn normal_style() -> Self {
95        Self::new(property_id::FONT_STYLE, value_type::F32, 0.0f32.to_bits())
96    }
97
98    /// `FONT_DESIGN` by enum code (`Default`=0, `Serif`=1, `Rounded`=2,
99    /// `Monospaced`=3).
100    #[must_use]
101    pub fn font_design(code: u8) -> Self {
102        Self::new(
103            property_id::FONT_DESIGN,
104            value_type::F32,
105            f32::from(code).to_bits(),
106        )
107    }
108
109    /// `UNDERLINE` (`0`/`1`).
110    #[must_use]
111    pub const fn underline(on: bool) -> Self {
112        Self::new(property_id::UNDERLINE, value_type::U8, on as u32)
113    }
114
115    /// `STRIKETHROUGH` (`0`/`1`).
116    #[must_use]
117    pub const fn strikethrough(on: bool) -> Self {
118        Self::new(property_id::STRIKETHROUGH, value_type::U8, on as u32)
119    }
120}
121
122/// A range of the text with a set of scalar attributes.
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct StyleRun {
125    /// Inclusive UTF-8 byte start.
126    pub start: u32,
127    /// Exclusive UTF-8 byte end.
128    pub end: u32,
129    /// The attributes applied to the range.
130    pub attributes: Vec<TextAttribute>,
131}
132
133impl StyleRun {
134    /// Create an empty style run. Ranges are validated against the text by
135    /// [`AttributedString::new`]/[`AttributedString::push_run`].
136    #[must_use]
137    pub fn new(start: u32, end: u32) -> Self {
138        Self {
139            start,
140            end,
141            attributes: Vec::new(),
142        }
143    }
144
145    /// Add an attribute (builder style).
146    #[must_use]
147    pub fn with(mut self, attribute: TextAttribute) -> Self {
148        self.attributes.push(attribute);
149        self
150    }
151
152    /// Mark the range bold.
153    #[must_use]
154    pub fn bold(self) -> Self {
155        self.with(TextAttribute::font_weight(700.0))
156    }
157
158    /// Mark the range italic.
159    #[must_use]
160    pub fn italic(self) -> Self {
161        self.with(TextAttribute::italic())
162    }
163
164    /// Mark the range monospaced.
165    #[must_use]
166    pub fn monospaced(self) -> Self {
167        self.with(TextAttribute::font_design(3))
168    }
169
170    /// Mark the range struck through.
171    #[must_use]
172    pub fn strikethrough(self) -> Self {
173        self.with(TextAttribute::strikethrough(true))
174    }
175
176    /// Mark the range underlined.
177    #[must_use]
178    pub fn underline(self) -> Self {
179        self.with(TextAttribute::underline(true))
180    }
181}
182
183/// A range of the text that is a hyperlink.
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct LinkRun {
186    /// Inclusive UTF-8 byte start.
187    pub start: u32,
188    /// Exclusive UTF-8 byte end.
189    pub end: u32,
190    /// The destination URL.
191    pub url: String,
192}
193
194impl LinkRun {
195    /// Create a link range.
196    #[must_use]
197    pub fn new(start: u32, end: u32, url: impl Into<String>) -> Self {
198        Self {
199            start,
200            end,
201            url: url.into(),
202        }
203    }
204}
205
206/// A non-overlapping slice of an [`AttributedString`] with its effective
207/// attributes and link. Produced by [`AttributedString::segments`].
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct AttributedSegment<'a> {
210    /// Inclusive UTF-8 byte start.
211    pub start: u32,
212    /// Exclusive UTF-8 byte end.
213    pub end: u32,
214    /// Effective attributes for this slice (later covering runs win).
215    pub attributes: Vec<TextAttribute>,
216    /// The effective link URL, if this slice is linked.
217    pub link: Option<&'a str>,
218}
219
220/// Text with optional style runs and links.
221#[derive(Debug, Clone, Default, PartialEq, Eq)]
222pub struct AttributedString {
223    text: String,
224    runs: Vec<StyleRun>,
225    links: Vec<LinkRun>,
226}
227
228impl AttributedString {
229    /// Create plain text with no runs or links.
230    #[must_use]
231    pub fn plain(text: impl Into<String>) -> Self {
232        Self {
233            text: text.into(),
234            runs: Vec::new(),
235            links: Vec::new(),
236        }
237    }
238
239    /// Create an attributed string from parts. Validates every range against
240    /// the text (bounds + UTF-8 boundaries).
241    pub fn new(
242        text: impl Into<String>,
243        runs: Vec<StyleRun>,
244        links: Vec<LinkRun>,
245    ) -> Result<Self, AttributedError> {
246        let value = Self {
247            text: text.into(),
248            runs,
249            links,
250        };
251        value.validate()?;
252        Ok(value)
253    }
254
255    /// The plain text.
256    #[must_use]
257    pub fn text(&self) -> &str {
258        &self.text
259    }
260
261    /// The style runs in wire order.
262    #[must_use]
263    pub fn runs(&self) -> &[StyleRun] {
264        &self.runs
265    }
266
267    /// The links in wire order.
268    #[must_use]
269    pub fn links(&self) -> &[LinkRun] {
270        &self.links
271    }
272
273    /// Whether this carries no runs or links.
274    #[must_use]
275    pub fn is_plain(&self) -> bool {
276        self.runs.is_empty() && self.links.is_empty()
277    }
278
279    /// Append a style run, validating it against the current text.
280    pub fn push_run(&mut self, run: StyleRun) -> Result<(), AttributedError> {
281        validate_range(&self.text, run.start, run.end)?;
282        self.runs.push(run);
283        Ok(())
284    }
285
286    /// Append a link, validating it against the current text.
287    pub fn push_link(&mut self, link: LinkRun) -> Result<(), AttributedError> {
288        validate_range(&self.text, link.start, link.end)?;
289        self.links.push(link);
290        Ok(())
291    }
292
293    /// Validate all ranges.
294    pub fn validate(&self) -> Result<(), AttributedError> {
295        if self.text.len() > u32::MAX as usize {
296            return Err(AttributedError::TextTooLong);
297        }
298        for run in &self.runs {
299            validate_range(&self.text, run.start, run.end)?;
300        }
301        for link in &self.links {
302            validate_range(&self.text, link.start, link.end)?;
303        }
304        Ok(())
305    }
306
307    /// Encode the arena payload (without the outer `[u32 byteLength]` wrapper
308    /// the arena adds). The caller allocates the returned bytes in the arena or
309    /// the network string section.
310    pub fn encode(&self) -> Result<Vec<u8>, AttributedError> {
311        self.validate()?;
312        let mut out = Vec::with_capacity(self.wire_len());
313        self.encode_into(&mut out)?;
314        Ok(out)
315    }
316
317    /// Encode into an existing buffer.
318    pub fn encode_into(&self, out: &mut Vec<u8>) -> Result<(), AttributedError> {
319        self.validate()?;
320        let text_len = u32::try_from(self.text.len()).map_err(|_| AttributedError::TextTooLong)?;
321        put_u32(out, text_len);
322        out.extend_from_slice(self.text.as_bytes());
323
324        put_u32(
325            out,
326            u32::try_from(self.runs.len()).map_err(|_| AttributedError::Malformed)?,
327        );
328        for run in &self.runs {
329            put_u32(out, run.start);
330            put_u32(out, run.end);
331            let supported: Vec<&TextAttribute> =
332                run.attributes.iter().filter(|a| is_supported(a)).collect();
333            put_u32(
334                out,
335                u32::try_from(supported.len()).map_err(|_| AttributedError::Malformed)?,
336            );
337            for attr in supported {
338                put_u16(out, attr.property);
339                out.push(attr.value_type);
340                out.push(0); // reserved
341                put_u32(out, attr.value);
342            }
343        }
344
345        put_u32(
346            out,
347            u32::try_from(self.links.len()).map_err(|_| AttributedError::Malformed)?,
348        );
349        for link in &self.links {
350            put_u32(out, link.start);
351            put_u32(out, link.end);
352            put_u32(
353                out,
354                u32::try_from(link.url.len()).map_err(|_| AttributedError::Malformed)?,
355            );
356            out.extend_from_slice(link.url.as_bytes());
357        }
358        Ok(())
359    }
360
361    /// Decode an arena payload (the bytes inside the arena's length wrapper).
362    pub fn decode(bytes: &[u8]) -> Result<Self, AttributedError> {
363        let mut r = Reader::new(bytes);
364        let text_len = r.u32()? as usize;
365        let text_bytes = r.take(text_len)?;
366        let text = core::str::from_utf8(text_bytes).map_err(|_| AttributedError::InvalidUtf8)?;
367
368        let run_count = r.u32()?;
369        let mut runs = Vec::new();
370        for _ in 0..run_count {
371            let start = r.u32()?;
372            let end = r.u32()?;
373            let attr_count = r.u32()?;
374            validate_range(text, start, end)?;
375            let mut attributes = Vec::new();
376            for _ in 0..attr_count {
377                let property = r.u16()?;
378                let value_type = r.u8()?;
379                let _reserved = r.u8()?;
380                let value = r.u32()?;
381                let attr = TextAttribute::new(property, value_type, value);
382                if is_supported(&attr) {
383                    attributes.push(attr);
384                }
385            }
386            runs.push(StyleRun {
387                start,
388                end,
389                attributes,
390            });
391        }
392
393        let link_count = r.u32()?;
394        let mut links = Vec::new();
395        for _ in 0..link_count {
396            let start = r.u32()?;
397            let end = r.u32()?;
398            let url_len = r.u32()? as usize;
399            validate_range(text, start, end)?;
400            let url =
401                core::str::from_utf8(r.take(url_len)?).map_err(|_| AttributedError::InvalidUtf8)?;
402            links.push(LinkRun {
403                start,
404                end,
405                url: url.into(),
406            });
407        }
408
409        if !r.is_empty() {
410            return Err(AttributedError::Malformed);
411        }
412        Ok(Self {
413            text: text.into(),
414            runs,
415            links,
416        })
417    }
418
419    /// Split the text into non-overlapping segments, resolving overlapping runs
420    /// in wire order (later wins per property; later link wins).
421    #[must_use]
422    pub fn segments(&self) -> Vec<AttributedSegment<'_>> {
423        let mut boundaries: Vec<u32> =
424            Vec::with_capacity(2 + self.runs.len() * 2 + self.links.len() * 2);
425        boundaries.push(0);
426        boundaries.push(self.text.len() as u32);
427        for run in &self.runs {
428            boundaries.push(run.start);
429            boundaries.push(run.end);
430        }
431        for link in &self.links {
432            boundaries.push(link.start);
433            boundaries.push(link.end);
434        }
435        boundaries.sort_unstable();
436        boundaries.dedup();
437
438        let mut segments = Vec::with_capacity(boundaries.len().saturating_sub(1));
439        for pair in boundaries.windows(2) {
440            let (start, end) = (pair[0], pair[1]);
441            if start == end {
442                continue;
443            }
444            let mut attributes: Vec<TextAttribute> = Vec::new();
445            for run in &self.runs {
446                if run.start <= start && run.end >= end {
447                    for attr in &run.attributes {
448                        if let Some(existing) =
449                            attributes.iter_mut().find(|a| a.property == attr.property)
450                        {
451                            *existing = *attr;
452                        } else {
453                            attributes.push(*attr);
454                        }
455                    }
456                }
457            }
458            let mut link = None;
459            for candidate in &self.links {
460                if candidate.start <= start && candidate.end >= end {
461                    link = Some(candidate.url.as_str());
462                }
463            }
464            segments.push(AttributedSegment {
465                start,
466                end,
467                attributes,
468                link,
469            });
470        }
471        segments
472    }
473
474    fn wire_len(&self) -> usize {
475        let mut len = 4 + self.text.len() + 4;
476        for run in &self.runs {
477            len += 12;
478            len += run.attributes.iter().filter(|a| is_supported(a)).count() * 8;
479        }
480        len += 4;
481        for link in &self.links {
482            len += 12 + link.url.len();
483        }
484        len
485    }
486}
487
488impl From<&str> for AttributedString {
489    fn from(value: &str) -> Self {
490        Self::plain(value)
491    }
492}
493
494impl From<String> for AttributedString {
495    fn from(value: String) -> Self {
496        Self::plain(value)
497    }
498}
499
500/// Whether an attribute is in the v1 run vocabulary.
501#[must_use]
502pub fn is_supported(attr: &TextAttribute) -> bool {
503    match attr.property {
504        property_id::FONT_SIZE | property_id::FONT_WEIGHT => attr.value_type == value_type::F32,
505        property_id::FONT_STYLE | property_id::FONT_DESIGN => {
506            attr.value_type == value_type::F32 || attr.value_type == value_type::ENUM
507        }
508        property_id::COLOR => attr.value_type == value_type::COLOR,
509        property_id::UNDERLINE | property_id::STRIKETHROUGH => attr.value_type == value_type::U8,
510        _ => false,
511    }
512}
513
514fn validate_range(text: &str, start: u32, end: u32) -> Result<(), AttributedError> {
515    if start > end {
516        return Err(AttributedError::InvalidRange);
517    }
518    let (start, end) = (start as usize, end as usize);
519    if end > text.len() || !text.is_char_boundary(start) || !text.is_char_boundary(end) {
520        return Err(AttributedError::RangeOutOfBounds);
521    }
522    Ok(())
523}
524
525fn put_u16(out: &mut Vec<u8>, value: u16) {
526    out.extend_from_slice(&value.to_le_bytes());
527}
528
529fn put_u32(out: &mut Vec<u8>, value: u32) {
530    out.extend_from_slice(&value.to_le_bytes());
531}
532
533struct Reader<'a> {
534    bytes: &'a [u8],
535    pos: usize,
536}
537
538impl<'a> Reader<'a> {
539    fn new(bytes: &'a [u8]) -> Self {
540        Self { bytes, pos: 0 }
541    }
542
543    fn take(&mut self, len: usize) -> Result<&'a [u8], AttributedError> {
544        let end = self
545            .pos
546            .checked_add(len)
547            .ok_or(AttributedError::Malformed)?;
548        if end > self.bytes.len() {
549            return Err(AttributedError::Malformed);
550        }
551        let slice = &self.bytes[self.pos..end];
552        self.pos = end;
553        Ok(slice)
554    }
555
556    fn u8(&mut self) -> Result<u8, AttributedError> {
557        Ok(self.take(1)?[0])
558    }
559
560    fn u16(&mut self) -> Result<u16, AttributedError> {
561        let b = self.take(2)?;
562        Ok(u16::from_le_bytes([b[0], b[1]]))
563    }
564
565    fn u32(&mut self) -> Result<u32, AttributedError> {
566        let b = self.take(4)?;
567        Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
568    }
569
570    fn is_empty(&self) -> bool {
571        self.pos == self.bytes.len()
572    }
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    use alloc::vec;
579
580    fn bold_and_link() -> AttributedString {
581        AttributedString::new(
582            "Hi",
583            vec![StyleRun::new(0, 2).bold()],
584            vec![LinkRun::new(1, 2, "x")],
585        )
586        .unwrap()
587    }
588
589    #[test]
590    fn encodes_the_conformance_payload() {
591        let value = bold_and_link();
592        let bytes = value.encode().unwrap();
593        assert_eq!(
594            bytes,
595            vec![
596                0x02, 0x00, 0x00, 0x00, // text len
597                0x48, 0x69, // "Hi"
598                0x01, 0x00, 0x00, 0x00, // run count
599                0x00, 0x00, 0x00, 0x00, // start
600                0x02, 0x00, 0x00, 0x00, // end
601                0x01, 0x00, 0x00, 0x00, // attr count
602                0x08, 0x10, 0x04, 0x00, 0x00, 0x00, 0x2F, 0x44, // FONT_WEIGHT 700
603                0x01, 0x00, 0x00, 0x00, // link count
604                0x01, 0x00, 0x00, 0x00, // start
605                0x02, 0x00, 0x00, 0x00, // end
606                0x01, 0x00, 0x00, 0x00, // url len
607                0x78, // "x"
608            ]
609        );
610    }
611
612    #[test]
613    fn round_trips() {
614        let value = bold_and_link();
615        assert_eq!(
616            AttributedString::decode(&value.encode().unwrap()).unwrap(),
617            value
618        );
619    }
620
621    #[test]
622    fn rejects_out_of_bounds_and_split_ranges() {
623        assert_eq!(
624            AttributedString::new("a", vec![StyleRun::new(0, 2)], vec![]),
625            Err(AttributedError::RangeOutOfBounds)
626        );
627        assert_eq!(
628            AttributedString::new("é", vec![StyleRun::new(1, 2)], vec![]),
629            Err(AttributedError::RangeOutOfBounds)
630        );
631        assert_eq!(
632            AttributedString::new("a", vec![StyleRun::new(1, 0)], vec![]),
633            Err(AttributedError::InvalidRange)
634        );
635    }
636
637    #[test]
638    fn ignores_unknown_attributes_and_truncated_payloads_fail() {
639        let mut value = bold_and_link();
640        value.runs[0]
641            .attributes
642            .push(TextAttribute::new(0x9999, value_type::F32, 0));
643        let bytes = value.encode().unwrap();
644        let decoded = AttributedString::decode(&bytes).unwrap();
645        assert_eq!(decoded.runs()[0].attributes.len(), 1);
646        assert_eq!(
647            AttributedString::decode(&bytes[..bytes.len() - 1]),
648            Err(AttributedError::Malformed)
649        );
650    }
651
652    #[test]
653    fn segments_resolve_overlaps_and_links() {
654        let value = AttributedString::new(
655            "abcd",
656            vec![
657                StyleRun::new(0, 3).bold(),
658                StyleRun::new(1, 4).italic(),
659                StyleRun::new(1, 2).with(TextAttribute::font_weight(400.0)),
660            ],
661            vec![LinkRun::new(2, 4, "x")],
662        )
663        .unwrap();
664        let segments = value.segments();
665        assert_eq!(segments.len(), 4);
666        assert_eq!((segments[0].start, segments[0].end), (0, 1));
667        assert_eq!(segments[0].attributes.len(), 1);
668        assert_eq!((segments[1].start, segments[1].end), (1, 2));
669        assert_eq!(segments[1].attributes.len(), 2);
670        assert!(segments[1]
671            .attributes
672            .iter()
673            .any(|a| a.property == property_id::FONT_WEIGHT && a.value == 400.0f32.to_bits()));
674        assert_eq!((segments[2].start, segments[2].end), (2, 3));
675        assert_eq!(segments[2].link, Some("x"));
676        assert_eq!((segments[3].start, segments[3].end), (3, 4));
677        assert_eq!(segments[3].link, Some("x"));
678    }
679
680    #[test]
681    fn rejects_trailing_bytes() {
682        let mut bytes = bold_and_link().encode().unwrap();
683        bytes.push(0);
684        assert_eq!(
685            AttributedString::decode(&bytes),
686            Err(AttributedError::Malformed)
687        );
688    }
689
690    #[test]
691    fn plain_text_is_empty_payload() {
692        assert!(AttributedString::plain("hi").is_plain());
693        assert_eq!(
694            AttributedString::decode(&AttributedString::plain("hi").encode().unwrap())
695                .unwrap()
696                .text(),
697            "hi"
698        );
699    }
700}