-
Notifications
You must be signed in to change notification settings - Fork 57
Add text_style + styled_text crates (CSS-like resolution + document blocks)
#495
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
waywardmonkeys
wants to merge
1
commit into
linebender:main
Choose a base branch
from
waywardmonkeys:stylamizing_yer_text
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| ); | ||
|
|
||
| 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, | ||
| )); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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.
InlineStyleallocates aVec<InlineDeclaration>..clear()for reuse, butapply_spantakes ownership, preventing scratch-storage patterns.StyledText::newallocates vectors for:AttributedText::attributes(span storage)ParagraphStyle::declarationsThere's no
StyledText::clear()orreset()to reuse these allocations across different text. For example,ParagraphStylelacks a.clear().Is
StyledTextwhere consumers will store their text model? Or do you seeStyledTextas a proxy that sits between user land models and various downstream text crates? I.e., does a user store their text model inStyledTextor do they lower their own model intoStyledText?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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
StyledTextis 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-levelAttributedText::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.There was a problem hiding this comment.
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_textandclear_*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_spantakes ownership of the attribute (InlineStyle), which means each span'sVec<InlineDeclaration>gets moved into the attributed text storage. Whenclear_attributes()is called, thoseInlineStylevalues 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
InlineStyleandStyledTextreturnsInlineStyleallocations in "clear"-like operations. But that may require API change (which perhaps could be additive).Separately, I've noted that there are other cases where we may want to control allocations (e.g. the
ResolvedInlineRunsstruct). So, another option would be for the entry point into anyStyledTextAPI to be through some context struct where we can manage allocations (durable pools and transient allocations) if we want to hide that from consumers: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.