use alloc::string::String;
use alloc::vec::Vec;
use crate::{property_id, value_type};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AttributedError {
InvalidRange,
RangeOutOfBounds,
TextTooLong,
Malformed,
InvalidUtf8,
}
impl core::fmt::Display for AttributedError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(match self {
AttributedError::InvalidRange => "invalid attributed-text range",
AttributedError::RangeOutOfBounds => "attributed-text range out of bounds",
AttributedError::TextTooLong => "attributed text exceeds the u32 wire length",
AttributedError::Malformed => "malformed attributed-text payload",
AttributedError::InvalidUtf8 => "attributed text is not valid UTF-8",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TextAttribute {
pub property: u16,
pub value_type: u8,
pub value: u32,
}
impl TextAttribute {
#[must_use]
pub const fn new(property: u16, value_type: u8, value: u32) -> Self {
Self {
property,
value_type,
value,
}
}
#[must_use]
pub fn font_size(points: f32) -> Self {
Self::new(property_id::FONT_SIZE, value_type::F32, points.to_bits())
}
#[must_use]
pub fn font_weight(weight: f32) -> Self {
Self::new(property_id::FONT_WEIGHT, value_type::F32, weight.to_bits())
}
#[must_use]
pub const fn color(argb: u32) -> Self {
Self::new(property_id::COLOR, value_type::COLOR, argb)
}
#[must_use]
pub fn italic() -> Self {
Self::new(property_id::FONT_STYLE, value_type::F32, 1.0f32.to_bits())
}
#[must_use]
pub fn normal_style() -> Self {
Self::new(property_id::FONT_STYLE, value_type::F32, 0.0f32.to_bits())
}
#[must_use]
pub fn font_design(code: u8) -> Self {
Self::new(
property_id::FONT_DESIGN,
value_type::F32,
f32::from(code).to_bits(),
)
}
#[must_use]
pub const fn underline(on: bool) -> Self {
Self::new(property_id::UNDERLINE, value_type::U8, on as u32)
}
#[must_use]
pub const fn strikethrough(on: bool) -> Self {
Self::new(property_id::STRIKETHROUGH, value_type::U8, on as u32)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StyleRun {
pub start: u32,
pub end: u32,
pub attributes: Vec<TextAttribute>,
}
impl StyleRun {
#[must_use]
pub fn new(start: u32, end: u32) -> Self {
Self {
start,
end,
attributes: Vec::new(),
}
}
#[must_use]
pub fn with(mut self, attribute: TextAttribute) -> Self {
self.attributes.push(attribute);
self
}
#[must_use]
pub fn bold(self) -> Self {
self.with(TextAttribute::font_weight(700.0))
}
#[must_use]
pub fn italic(self) -> Self {
self.with(TextAttribute::italic())
}
#[must_use]
pub fn monospaced(self) -> Self {
self.with(TextAttribute::font_design(3))
}
#[must_use]
pub fn strikethrough(self) -> Self {
self.with(TextAttribute::strikethrough(true))
}
#[must_use]
pub fn underline(self) -> Self {
self.with(TextAttribute::underline(true))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LinkRun {
pub start: u32,
pub end: u32,
pub url: String,
}
impl LinkRun {
#[must_use]
pub fn new(start: u32, end: u32, url: impl Into<String>) -> Self {
Self {
start,
end,
url: url.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AttributedSegment<'a> {
pub start: u32,
pub end: u32,
pub attributes: Vec<TextAttribute>,
pub link: Option<&'a str>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AttributedString {
text: String,
runs: Vec<StyleRun>,
links: Vec<LinkRun>,
}
impl AttributedString {
#[must_use]
pub fn plain(text: impl Into<String>) -> Self {
Self {
text: text.into(),
runs: Vec::new(),
links: Vec::new(),
}
}
pub fn new(
text: impl Into<String>,
runs: Vec<StyleRun>,
links: Vec<LinkRun>,
) -> Result<Self, AttributedError> {
let value = Self {
text: text.into(),
runs,
links,
};
value.validate()?;
Ok(value)
}
#[must_use]
pub fn text(&self) -> &str {
&self.text
}
#[must_use]
pub fn runs(&self) -> &[StyleRun] {
&self.runs
}
#[must_use]
pub fn links(&self) -> &[LinkRun] {
&self.links
}
#[must_use]
pub fn is_plain(&self) -> bool {
self.runs.is_empty() && self.links.is_empty()
}
pub fn push_run(&mut self, run: StyleRun) -> Result<(), AttributedError> {
validate_range(&self.text, run.start, run.end)?;
self.runs.push(run);
Ok(())
}
pub fn push_link(&mut self, link: LinkRun) -> Result<(), AttributedError> {
validate_range(&self.text, link.start, link.end)?;
self.links.push(link);
Ok(())
}
pub fn validate(&self) -> Result<(), AttributedError> {
if self.text.len() > u32::MAX as usize {
return Err(AttributedError::TextTooLong);
}
for run in &self.runs {
validate_range(&self.text, run.start, run.end)?;
}
for link in &self.links {
validate_range(&self.text, link.start, link.end)?;
}
Ok(())
}
pub fn encode(&self) -> Result<Vec<u8>, AttributedError> {
self.validate()?;
let mut out = Vec::with_capacity(self.wire_len());
self.encode_into(&mut out)?;
Ok(out)
}
pub fn encode_into(&self, out: &mut Vec<u8>) -> Result<(), AttributedError> {
self.validate()?;
let text_len = u32::try_from(self.text.len()).map_err(|_| AttributedError::TextTooLong)?;
put_u32(out, text_len);
out.extend_from_slice(self.text.as_bytes());
put_u32(
out,
u32::try_from(self.runs.len()).map_err(|_| AttributedError::Malformed)?,
);
for run in &self.runs {
put_u32(out, run.start);
put_u32(out, run.end);
let supported: Vec<&TextAttribute> =
run.attributes.iter().filter(|a| is_supported(a)).collect();
put_u32(
out,
u32::try_from(supported.len()).map_err(|_| AttributedError::Malformed)?,
);
for attr in supported {
put_u16(out, attr.property);
out.push(attr.value_type);
out.push(0); put_u32(out, attr.value);
}
}
put_u32(
out,
u32::try_from(self.links.len()).map_err(|_| AttributedError::Malformed)?,
);
for link in &self.links {
put_u32(out, link.start);
put_u32(out, link.end);
put_u32(
out,
u32::try_from(link.url.len()).map_err(|_| AttributedError::Malformed)?,
);
out.extend_from_slice(link.url.as_bytes());
}
Ok(())
}
pub fn decode(bytes: &[u8]) -> Result<Self, AttributedError> {
let mut r = Reader::new(bytes);
let text_len = r.u32()? as usize;
let text_bytes = r.take(text_len)?;
let text = core::str::from_utf8(text_bytes).map_err(|_| AttributedError::InvalidUtf8)?;
let run_count = r.u32()?;
let mut runs = Vec::new();
for _ in 0..run_count {
let start = r.u32()?;
let end = r.u32()?;
let attr_count = r.u32()?;
validate_range(text, start, end)?;
let mut attributes = Vec::new();
for _ in 0..attr_count {
let property = r.u16()?;
let value_type = r.u8()?;
let _reserved = r.u8()?;
let value = r.u32()?;
let attr = TextAttribute::new(property, value_type, value);
if is_supported(&attr) {
attributes.push(attr);
}
}
runs.push(StyleRun {
start,
end,
attributes,
});
}
let link_count = r.u32()?;
let mut links = Vec::new();
for _ in 0..link_count {
let start = r.u32()?;
let end = r.u32()?;
let url_len = r.u32()? as usize;
validate_range(text, start, end)?;
let url =
core::str::from_utf8(r.take(url_len)?).map_err(|_| AttributedError::InvalidUtf8)?;
links.push(LinkRun {
start,
end,
url: url.into(),
});
}
if !r.is_empty() {
return Err(AttributedError::Malformed);
}
Ok(Self {
text: text.into(),
runs,
links,
})
}
#[must_use]
pub fn segments(&self) -> Vec<AttributedSegment<'_>> {
let mut boundaries: Vec<u32> =
Vec::with_capacity(2 + self.runs.len() * 2 + self.links.len() * 2);
boundaries.push(0);
boundaries.push(self.text.len() as u32);
for run in &self.runs {
boundaries.push(run.start);
boundaries.push(run.end);
}
for link in &self.links {
boundaries.push(link.start);
boundaries.push(link.end);
}
boundaries.sort_unstable();
boundaries.dedup();
let mut segments = Vec::with_capacity(boundaries.len().saturating_sub(1));
for pair in boundaries.windows(2) {
let (start, end) = (pair[0], pair[1]);
if start == end {
continue;
}
let mut attributes: Vec<TextAttribute> = Vec::new();
for run in &self.runs {
if run.start <= start && run.end >= end {
for attr in &run.attributes {
if let Some(existing) =
attributes.iter_mut().find(|a| a.property == attr.property)
{
*existing = *attr;
} else {
attributes.push(*attr);
}
}
}
}
let mut link = None;
for candidate in &self.links {
if candidate.start <= start && candidate.end >= end {
link = Some(candidate.url.as_str());
}
}
segments.push(AttributedSegment {
start,
end,
attributes,
link,
});
}
segments
}
fn wire_len(&self) -> usize {
let mut len = 4 + self.text.len() + 4;
for run in &self.runs {
len += 12;
len += run.attributes.iter().filter(|a| is_supported(a)).count() * 8;
}
len += 4;
for link in &self.links {
len += 12 + link.url.len();
}
len
}
}
impl From<&str> for AttributedString {
fn from(value: &str) -> Self {
Self::plain(value)
}
}
impl From<String> for AttributedString {
fn from(value: String) -> Self {
Self::plain(value)
}
}
#[must_use]
pub fn is_supported(attr: &TextAttribute) -> bool {
match attr.property {
property_id::FONT_SIZE | property_id::FONT_WEIGHT => attr.value_type == value_type::F32,
property_id::FONT_STYLE | property_id::FONT_DESIGN => {
attr.value_type == value_type::F32 || attr.value_type == value_type::ENUM
}
property_id::COLOR => attr.value_type == value_type::COLOR,
property_id::UNDERLINE | property_id::STRIKETHROUGH => attr.value_type == value_type::U8,
_ => false,
}
}
fn validate_range(text: &str, start: u32, end: u32) -> Result<(), AttributedError> {
if start > end {
return Err(AttributedError::InvalidRange);
}
let (start, end) = (start as usize, end as usize);
if end > text.len() || !text.is_char_boundary(start) || !text.is_char_boundary(end) {
return Err(AttributedError::RangeOutOfBounds);
}
Ok(())
}
fn put_u16(out: &mut Vec<u8>, value: u16) {
out.extend_from_slice(&value.to_le_bytes());
}
fn put_u32(out: &mut Vec<u8>, value: u32) {
out.extend_from_slice(&value.to_le_bytes());
}
struct Reader<'a> {
bytes: &'a [u8],
pos: usize,
}
impl<'a> Reader<'a> {
fn new(bytes: &'a [u8]) -> Self {
Self { bytes, pos: 0 }
}
fn take(&mut self, len: usize) -> Result<&'a [u8], AttributedError> {
let end = self
.pos
.checked_add(len)
.ok_or(AttributedError::Malformed)?;
if end > self.bytes.len() {
return Err(AttributedError::Malformed);
}
let slice = &self.bytes[self.pos..end];
self.pos = end;
Ok(slice)
}
fn u8(&mut self) -> Result<u8, AttributedError> {
Ok(self.take(1)?[0])
}
fn u16(&mut self) -> Result<u16, AttributedError> {
let b = self.take(2)?;
Ok(u16::from_le_bytes([b[0], b[1]]))
}
fn u32(&mut self) -> Result<u32, AttributedError> {
let b = self.take(4)?;
Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
}
fn is_empty(&self) -> bool {
self.pos == self.bytes.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use alloc::vec;
fn bold_and_link() -> AttributedString {
AttributedString::new(
"Hi",
vec![StyleRun::new(0, 2).bold()],
vec![LinkRun::new(1, 2, "x")],
)
.unwrap()
}
#[test]
fn encodes_the_conformance_payload() {
let value = bold_and_link();
let bytes = value.encode().unwrap();
assert_eq!(
bytes,
vec![
0x02, 0x00, 0x00, 0x00, 0x48, 0x69, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x08, 0x10, 0x04, 0x00, 0x00, 0x00, 0x2F, 0x44, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x78, ]
);
}
#[test]
fn round_trips() {
let value = bold_and_link();
assert_eq!(
AttributedString::decode(&value.encode().unwrap()).unwrap(),
value
);
}
#[test]
fn rejects_out_of_bounds_and_split_ranges() {
assert_eq!(
AttributedString::new("a", vec![StyleRun::new(0, 2)], vec![]),
Err(AttributedError::RangeOutOfBounds)
);
assert_eq!(
AttributedString::new("é", vec![StyleRun::new(1, 2)], vec![]),
Err(AttributedError::RangeOutOfBounds)
);
assert_eq!(
AttributedString::new("a", vec![StyleRun::new(1, 0)], vec![]),
Err(AttributedError::InvalidRange)
);
}
#[test]
fn ignores_unknown_attributes_and_truncated_payloads_fail() {
let mut value = bold_and_link();
value.runs[0]
.attributes
.push(TextAttribute::new(0x9999, value_type::F32, 0));
let bytes = value.encode().unwrap();
let decoded = AttributedString::decode(&bytes).unwrap();
assert_eq!(decoded.runs()[0].attributes.len(), 1);
assert_eq!(
AttributedString::decode(&bytes[..bytes.len() - 1]),
Err(AttributedError::Malformed)
);
}
#[test]
fn segments_resolve_overlaps_and_links() {
let value = AttributedString::new(
"abcd",
vec![
StyleRun::new(0, 3).bold(),
StyleRun::new(1, 4).italic(),
StyleRun::new(1, 2).with(TextAttribute::font_weight(400.0)),
],
vec![LinkRun::new(2, 4, "x")],
)
.unwrap();
let segments = value.segments();
assert_eq!(segments.len(), 4);
assert_eq!((segments[0].start, segments[0].end), (0, 1));
assert_eq!(segments[0].attributes.len(), 1);
assert_eq!((segments[1].start, segments[1].end), (1, 2));
assert_eq!(segments[1].attributes.len(), 2);
assert!(segments[1]
.attributes
.iter()
.any(|a| a.property == property_id::FONT_WEIGHT && a.value == 400.0f32.to_bits()));
assert_eq!((segments[2].start, segments[2].end), (2, 3));
assert_eq!(segments[2].link, Some("x"));
assert_eq!((segments[3].start, segments[3].end), (3, 4));
assert_eq!(segments[3].link, Some("x"));
}
#[test]
fn rejects_trailing_bytes() {
let mut bytes = bold_and_link().encode().unwrap();
bytes.push(0);
assert_eq!(
AttributedString::decode(&bytes),
Err(AttributedError::Malformed)
);
}
#[test]
fn plain_text_is_empty_payload() {
assert!(AttributedString::plain("hi").is_plain());
assert_eq!(
AttributedString::decode(&AttributedString::plain("hi").encode().unwrap())
.unwrap()
.text(),
"hi"
);
}
}