Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ jobs:
- name: Run cargo rdme (parley_core)
run: cargo rdme --check --heading-base-level=0 --workspace-project=parley_core

- name: Run cargo rdme (styled_text)
run: cargo rdme --check --heading-base-level=0 --workspace-project=styled_text

- name: Run cargo rdme (styled_text_parley)
run: cargo rdme --check --heading-base-level=0 --workspace-project=styled_text_parley

- name: Run cargo rdme (text_primitives)
run: cargo rdme --check --heading-base-level=0 --workspace-project=text_primitives

Expand Down
28 changes: 28 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@ members = [
"parley_data_gen",
"parley_dev",
"parley_draw",
"styled_text",
"text_primitives",
"styled_text_parley",
"examples/swash_render",
"examples/tiny_skia_render",
"examples/vello_cpu_render",
"examples/vello_cpu_render_styled_text",
"examples/vello_editor",
"xtask",
]
Expand All @@ -28,6 +31,7 @@ repository = "https://github.com/linebender/parley"

[workspace.dependencies]
accesskit = "0.22.0"
attributed_text = { path = "attributed_text", default-features = false }
bytemuck = { version = "1.24.0", default-features = false }
databake = { version = "0.2", default-features = false }
fontique = { version = "0.7.0", default-features = false, path = "fontique" }
Expand Down Expand Up @@ -57,7 +61,10 @@ peniko = { version = "0.4.1", default-features = false }
read-fonts = { version = "0.37.0", default-features = false }
skrifa = { version = "0.40.0", default-features = false }
smallvec = "1.15.1"
styled_text = { path = "styled_text", default-features = false }
styled_text_parley = { path = "styled_text_parley", default-features = false }
swash = { version = "0.2.6", default-features = false }
text_primitives = { path = "text_primitives", default-features = false }
vello_common = { git = "https://github.com/linebender/vello.git", rev = "ab948fed9d6a0a79bb0eb7c72043a65b7b7a391f", default-features = false }
vello_cpu = { git = "https://github.com/linebender/vello.git", rev = "ab948fed9d6a0a79bb0eb7c72043a65b7b7a391f", default-features = false }
zerovec = { version = "0.11.5", default-features = false }
Expand Down
18 changes: 18 additions & 0 deletions examples/vello_cpu_render_styled_text/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[package]
name = "vello_cpu_render_styled_text"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
publish = false

[dependencies]
parley = { workspace = true, default-features = true }
parley_draw = { workspace = true, default-features = true, features = ["vello_cpu", "png"] }
styled_text = { workspace = true }
styled_text_parley = { workspace = true }
vello_cpu = { workspace = true, default-features = true, features = ["png"] }

[lints]
workspace = true
253 changes: 253 additions & 0 deletions examples/vello_cpu_render_styled_text/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
// Copyright 2025 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT

//! Renders styled text using `styled_text` + `styled_text_parley`, then paints glyph outlines using
//! Vello CPU through Parley Draw.
//!
//! Note: Emoji rendering is not currently implemented in this example. See the swash example if
//! you need emoji rendering.

#![expect(clippy::cast_possible_truncation, reason = "Deferred")]

use core::ops::Range;

use parley::{
Alignment, AlignmentOptions, FontContext, GlyphRun, Layout, LayoutContext, PositionedLayoutItem,
};
use parley_draw::{GlyphCaches, GlyphRunBuilder};
use styled_text::{
ComputedInlineStyle, ComputedParagraphStyle, FontFeature, FontFeatures, FontSize,
FontVariation, FontVariations, InlineStyle, StyledText, Tag,
};
use styled_text_parley::build_layout_from_styled_text;
use vello_cpu::{RenderContext, kurbo, peniko::Color};

#[derive(Clone, Copy, Debug, PartialEq)]
struct ColorBrush {
color: Color,
}

impl Default for ColorBrush {
fn default() -> Self {
Self {
color: Color::BLACK,
}
}
}

fn find_range(text: &str, needle: &str) -> Range<usize> {
let start = text
.find(needle)
.unwrap_or_else(|| panic!("missing substring {needle:?}"));
start..start + needle.len()
}

fn main() {
let text = concat!(
"StyledText + Parley\n",
"A small, no_std + alloc rich-text layer.\n",
"\n",
"BIG, tiny, underline, strike, and OpenType settings.\n",
"Some bidirectional sample: English العربية.\n"
)
.to_string();

let display_scale = 1.0;
let quantize = true;
let max_advance = Some(520.0 * display_scale);

let foreground_color = Color::BLACK;
let background_color = Color::from_rgb8(250, 250, 252);
let padding: u16 = 24;

let base_inline = ComputedInlineStyle::default().with_font_size_px(18.0);
let base_paragraph = ComputedParagraphStyle::default();
let mut styled = StyledText::new(text.as_str(), base_inline, base_paragraph);

let styled_text_style = InlineStyle::new()
.with_font_size(FontSize::Px(42.0))
.with_underline(true);
let parley_style = InlineStyle::new()
.with_font_size(FontSize::Px(30.0))
.with_strikethrough(true);
let big_style = InlineStyle::new().with_font_size(FontSize::Em(2.0));
let tiny_style = InlineStyle::new().with_font_size(FontSize::Px(12.0));
let underline_style = InlineStyle::new().with_underline(true);
let strike_style = InlineStyle::new().with_strikethrough(true);
let opentype_features_style = InlineStyle::new().with_font_features(FontFeatures::list(vec![
FontFeature::new(Tag::new(b"liga"), 0),
FontFeature::new(Tag::new(b"kern"), 0),
]));
let variation_style =
InlineStyle::new().with_font_variations(FontVariations::list(vec![FontVariation::new(
Tag::new(b"wght"),
750.0,
)]));

styled.apply_span(
styled.range(find_range(&text, "StyledText")).unwrap(),
styled_text_style,
Copy link
Contributor

@taj-p taj-p Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are your thoughts about the allocation patterns of this API?

It's currently difficult to reuse allocations across evolutions of rich text.

InlineStyle allocates a Vec<InlineDeclaration>.

  • It has .clear() for reuse, but apply_span takes ownership, preventing scratch-storage patterns.

StyledText::new allocates vectors for:

  • AttributedText::attributes (span storage)
  • ParagraphStyle::declarations

There's no StyledText::clear() or reset() to reuse these allocations across different text. For example, ParagraphStyle lacks a .clear().

Is StyledText where consumers will store their text model? Or do you see StyledText as a proxy that sits between user land models and various downstream text crates? I.e., does a user store their text model in StyledText or do they lower their own model into StyledText?

Going by the current API, given the "write once, read for layout" approach and inability to "read back" styles that were subsequently input, I believe this is a transient intermediary - is that true?

If so, then I think we will want to support a "clear and refill" pattern to allow for allocation reuse across builds and have a stated goal of cheap construction and rebuild.


That said, perhaps I've totally missed the mark here, which I am wont to do 😅 !!! So, I would appreciate more context if that's the case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StyledText is intended to be a durable attributed-text model (think “attributed string”): it can be retained as an app’s in-memory representation (often parsed from markup), but it can also be used transiently as a layout input if you already have your own model. We should document that intent more clearly.

On allocation reuse: agreed. Even without a full editing/mutation story yet, it should support a “clear and refill” rebuild pattern so consumers can reuse capacity across evolutions of the same document. I’ve added StyledText::set_text/clear_*-style APIs (and lower-level AttributedText::set_text + ParagraphStyle::clear) that clear spans/declarations while retaining storage. Longer-term, we’ll need richer mutation APIs (insert/delete with span adjustment), but this is a good first step toward cheap rebuilds.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love that clean change to support a better clear and refill pattern via set_text and clear_* API. Very nice!!

One question I am wondering about is how we would tackle the allocations lost to InlineStyle::declarations?

I think if we aim to be both a durable model and a transient stepping stone to rich text layout, we might want to address those allocations.

Currently, apply_span takes ownership of the attribute (InlineStyle), which means each span's Vec<InlineDeclaration> gets moved into the attributed text storage. When clear_attributes() is called, those InlineStyle values are dropped along with their internal allocations (i.e. InlineStyles::declarations).

With the current API, I imagine we could use a pooling object from which consumers get the InlineStyle and StyledText returns InlineStyle allocations in "clear"-like operations. But that may require API change (which perhaps could be additive).

let mut pool = InlineStylePool::new();

let style = pool.get();
style.push_declaration(...);
styled.apply_span(range, style);

styled.clear_spans_into(&mut pool);

Separately, I've noted that there are other cases where we may want to control allocations (e.g. the ResolvedInlineRuns struct). So, another option would be for the entry point into any StyledText API to be through some context struct where we can manage allocations (durable pools and transient allocations) if we want to hide that from consumers:

let ctx = StyledTextContext::new();
let inline_style = ctx.create_inline_style();
let styled_text = ctx.create_styled_text();

...

ctx.resolve_inline_runs(styled_text); // ctx manages re-useable allocations for `resolve_inline_runs`

This approach may better balance the goal of minimal allocations and API ergonomics - otherwise, to achieve minimal allocations, consumers may need to manage cache objects and pass them in on an as-needs basis (but that approach may also work!).

In any case, I highlight this case for your consideration - perhaps it's something that may help direct strategy.

);

styled.apply_span(
styled.range(find_range(&text, "Parley")).unwrap(),
parley_style,
);

styled.apply_span(styled.range(find_range(&text, "BIG")).unwrap(), big_style);

styled.apply_span(styled.range(find_range(&text, "tiny")).unwrap(), tiny_style);

styled.apply_span(
styled.range(find_range(&text, "underline")).unwrap(),
underline_style,
);

styled.apply_span(
styled.range(find_range(&text, "strike")).unwrap(),
strike_style,
);

styled.apply_span(
styled
.range(find_range(&text, "OpenType settings"))
.unwrap(),
opentype_features_style,
);

styled.apply_span(
styled.range(find_range(&text, "rich-text")).unwrap(),
variation_style,
);

let mut font_cx = FontContext::new();
let mut layout_cx = LayoutContext::new();

let foreground_brush = ColorBrush {
color: foreground_color,
};
let mut layout: Layout<ColorBrush> = build_layout_from_styled_text(
&mut layout_cx,
&mut font_cx,
&styled,
display_scale,
quantize,
foreground_brush,
);

layout.break_all_lines(max_advance);
layout.align(max_advance, Alignment::Start, AlignmentOptions::default());

let width = layout.width().ceil() as u16;
let height = layout.height().ceil() as u16;
let padded_width = width + padding * 2;
let padded_height = height + padding * 2;

let mut renderer = RenderContext::new(padded_width, padded_height);
let mut glyph_caches = GlyphCaches::new();

renderer.set_paint(background_color);
renderer.fill_rect(&kurbo::Rect::new(
0.0,
0.0,
padded_width as f64,
padded_height as f64,
));
renderer.set_transform(kurbo::Affine::translate(kurbo::Vec2::new(
padding as f64,
padding as f64,
)));

for line in layout.lines() {
for item in line.items() {
match item {
PositionedLayoutItem::GlyphRun(glyph_run) => {
render_glyph_run(&mut renderer, &mut glyph_caches, &glyph_run);
}
PositionedLayoutItem::InlineBox(inline_box) => {
renderer.set_paint(foreground_color);
let (x0, y0) = (inline_box.x as f64, inline_box.y as f64);
let (x1, y1) = (x0 + inline_box.width as f64, y0 + inline_box.height as f64);
renderer.fill_rect(&kurbo::Rect::new(x0, y0, x1, y1));
}
}
}
}

let mut pixmap = vello_cpu::Pixmap::new(padded_width, padded_height);
renderer.render_to_pixmap(&mut pixmap);
glyph_caches.maintain();

let output_path = {
let path = std::path::PathBuf::from(file!());
let mut path = std::fs::canonicalize(path).unwrap();
path.pop();
path.pop();
path.pop();
path.push("_output");
drop(std::fs::create_dir(path.clone()));
path.push("vello_cpu_render_styled_text.png");
path
};
let png = pixmap.into_png().unwrap();
std::fs::write(output_path, png).unwrap();
}

fn render_glyph_run(
renderer: &mut RenderContext,
glyph_caches: &mut GlyphCaches,
glyph_run: &GlyphRun<'_, ColorBrush>,
) {
renderer.set_paint(glyph_run.style().brush.color);
let run = glyph_run.run();
GlyphRunBuilder::new(run.font().clone(), *renderer.transform(), renderer)
.font_size(run.font_size())
.hint(true)
.normalized_coords(run.normalized_coords())
.fill_glyphs(
glyph_run
.positioned_glyphs()
.map(|glyph| parley_draw::Glyph {
id: glyph.id,
x: glyph.x,
y: glyph.y,
}),
glyph_caches,
);

let style = glyph_run.style();
if let Some(decoration) = &style.underline {
let offset = decoration.offset.unwrap_or(run.metrics().underline_offset);
let size = decoration.size.unwrap_or(run.metrics().underline_size);
render_decoration(renderer, &decoration.brush, glyph_run, offset, size);
}
if let Some(decoration) = &style.strikethrough {
let offset = decoration
.offset
.unwrap_or(run.metrics().strikethrough_offset);
let size = decoration.size.unwrap_or(run.metrics().strikethrough_size);
render_decoration(renderer, &decoration.brush, glyph_run, offset, size);
}
}

fn render_decoration(
renderer: &mut RenderContext,
brush: &ColorBrush,
glyph_run: &GlyphRun<'_, ColorBrush>,
offset: f32,
size: f32,
) {
renderer.set_paint(brush.color);

let run = glyph_run.run();
let x0 = glyph_run.offset();
let x1 = x0 + run.advance();
let y = glyph_run.baseline() - offset;

renderer.fill_rect(&kurbo::Rect::new(
x0 as f64,
(y - size * 0.5) as f64,
x1 as f64,
(y + size * 0.5) as f64,
));
}
29 changes: 29 additions & 0 deletions styled_text/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
[package]
name = "styled_text"
version = "0.1.0"
description = "Attributed text + CSS-inspired span styles"
keywords = ["text", "style", "attributed text"]
categories = ["graphics", "text"]
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true

publish = false

[package.metadata.docs.rs]
all-features = true
# There are no platform specific docs.
default-target = "x86_64-unknown-linux-gnu"
targets = []

[features]
default = ["std"]
std = []

[dependencies]
attributed_text.workspace = true
text_primitives.workspace = true

[lints]
workspace = true
Loading