diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18f45560..bc118686 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index f649dfa2..57e87d09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4286,6 +4286,23 @@ dependencies = [ "syn", ] +[[package]] +name = "styled_text" +version = "0.1.0" +dependencies = [ + "attributed_text", + "text_primitives", +] + +[[package]] +name = "styled_text_parley" +version = "0.1.0" +dependencies = [ + "attributed_text", + "parley", + "styled_text", +] + [[package]] name = "subtle" version = "2.6.1" @@ -4878,6 +4895,17 @@ dependencies = [ "vello_cpu", ] +[[package]] +name = "vello_cpu_render_styled_text" +version = "0.1.0" +dependencies = [ + "parley", + "parley_draw", + "styled_text", + "styled_text_parley", + "vello_cpu", +] + [[package]] name = "vello_editor" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 9bcdf418..202406f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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", ] @@ -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" } @@ -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 } diff --git a/examples/vello_cpu_render_styled_text/Cargo.toml b/examples/vello_cpu_render_styled_text/Cargo.toml new file mode 100644 index 00000000..7da2108f --- /dev/null +++ b/examples/vello_cpu_render_styled_text/Cargo.toml @@ -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 diff --git a/examples/vello_cpu_render_styled_text/src/main.rs b/examples/vello_cpu_render_styled_text/src/main.rs new file mode 100644 index 00000000..3348e3ad --- /dev/null +++ b/examples/vello_cpu_render_styled_text/src/main.rs @@ -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 { + 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 = 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, + )); +} diff --git a/styled_text/Cargo.toml b/styled_text/Cargo.toml new file mode 100644 index 00000000..dbc7c4c4 --- /dev/null +++ b/styled_text/Cargo.toml @@ -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 diff --git a/styled_text/LICENSE-APACHE b/styled_text/LICENSE-APACHE new file mode 100644 index 00000000..d9a10c0d --- /dev/null +++ b/styled_text/LICENSE-APACHE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/styled_text/LICENSE-MIT b/styled_text/LICENSE-MIT new file mode 100644 index 00000000..9cf10627 --- /dev/null +++ b/styled_text/LICENSE-MIT @@ -0,0 +1,19 @@ +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/styled_text/README.md b/styled_text/README.md new file mode 100644 index 00000000..b360f9b5 --- /dev/null +++ b/styled_text/README.md @@ -0,0 +1,163 @@ +
+ +# Styled Text + +Attributed text + CSS-inspired span styles + a lightweight document block model. + +[![Linebender Zulip, #parley channel](https://img.shields.io/badge/Linebender-%23parley-blue?logo=Zulip)](https://xi.zulipchat.com/#narrow/channel/205635-parley) +[![dependency status](https://deps.rs/repo/github/linebender/parley/status.svg)](https://deps.rs/repo/github/linebender/parley) +[![Apache 2.0 or MIT license.](https://img.shields.io/badge/license-Apache--2.0_OR_MIT-blue.svg)](#license) +[![Build status](https://github.com/linebender/parley/workflows/CI/badge.svg)](https://github.com/linebender/parley/actions) +[![Crates.io](https://img.shields.io/crates/v/styled_text.svg)](https://crates.io/crates/styled_text) +[![Docs](https://docs.rs/styled_text/badge.svg)](https://docs.rs/styled_text) + +
+ + + + + + +Span styling and document structure built on [`attributed_text`]. + +- [`style`] defines a closed style vocabulary. +- [`resolve`] resolves specified styles to computed styles. +- [`attributed_text`] stores generic attributes on byte ranges. +- `styled_text` combines them: + - [`StyledText`]: a single layout block (maps cleanly to one Parley `Layout`) + - [`StyledDocument`]: a flat sequence of blocks with semantic kinds (headings, list items…) + +## Scope + +This crate provides span application, inline style run resolution, and a lightweight block +model. It does not itself lower styles to Parley APIs, and it does not define paint/brush types +(those are expected to live in wrapper attributes and an engine-lowering layer). + +## Design Intent + +`StyledText` is intended to be a durable attributed-text model: +- it can be parsed from markup and retained as an application’s in-memory representation +- it can be used transiently as a “layout input packet” if you already have your own model +- it aims to be a reasonable interchange format for rich text (for example copy/paste) + +The mutation/editing story is still evolving. Short-term APIs focus on span application and +layout-facing iteration; richer mutation patterns (inserts/deletes with span adjustment, etc.) +are expected to be added over time. + +## Indices + +All ranges are expressed as **byte indices** into UTF-8 text, and must be on UTF-8 character +boundaries (as required by [`attributed_text`]). + +## Overlaps + +When spans overlap, inline style resolution applies spans in the order they were added (last +writer wins). Higher-level semantic attributes can be carried by wrapper types via +[`HasInlineStyle`]. + +## Example: Styled spans + +```rust +use styled_text::StyledText; +use styled_text::style::{FontSize, InlineStyle, Specified}; +use styled_text::resolve::{ComputedInlineStyle, ComputedParagraphStyle}; + +let base_inline = ComputedInlineStyle::default(); +let base_paragraph = ComputedParagraphStyle::default(); +let mut text = StyledText::new("Hello world!", base_inline, base_paragraph); + +// Make "world!" 1.5x larger. +let world = 6..12; +let style = InlineStyle::new().font_size(Specified::Value(FontSize::Em(1.5))); +text.apply_span(text.range(world).unwrap(), style); + +let runs: Vec<_> = text + .resolved_inline_runs_coalesced() + .collect(); +assert_eq!(runs.len(), 2); +assert_eq!(runs[1].range, 6..12); +``` + +## Example: Wrapper attributes for semantics + +```rust +use alloc::sync::Arc; +use styled_text::{HasInlineStyle, StyledText}; +use styled_text::style::InlineStyle; +use styled_text::resolve::{ComputedInlineStyle, ComputedParagraphStyle}; + +#[derive(Debug, Clone)] +struct Attr { + style: InlineStyle, + href: Option>, +} + +impl HasInlineStyle for Attr { + fn inline_style(&self) -> &InlineStyle { + &self.style + } +} + +let base_inline = ComputedInlineStyle::default(); +let base_paragraph = ComputedParagraphStyle::default(); +let mut text = StyledText::new("Click me", base_inline, base_paragraph); +text.apply_span( + text.range(0..8).unwrap(), + Attr { + style: InlineStyle::new(), + href: Some(Arc::from("https://example.invalid")), + }, +); +``` + + + +## Minimum supported Rust Version (MSRV) + +This version of Styled Text has been verified to compile with **Rust 1.83** and later. + +Future versions of Styled Text might increase the Rust version requirement. +It will not be treated as a breaking change and as such can even happen with small patch releases. + +
+Click here if compiling fails. + +As time has passed, some of Styled Text's dependencies could have released versions with a higher Rust requirement. +If you encounter a compilation issue due to a dependency and don't want to upgrade your Rust toolchain, then you could downgrade the dependency. + +```sh +# Use the problematic dependency's name and version +cargo update -p package_name --precise 0.1.1 +``` + +
+ +## Community + +[![Linebender Zulip](https://img.shields.io/badge/Xi%20Zulip-%23parley-blue?logo=Zulip)](https://xi.zulipchat.com/#narrow/channel/205635-parley) + +Discussion of Styled Text development happens in the [Linebender Zulip](https://xi.zulipchat.com/), specifically the [#parley channel](https://xi.zulipchat.com/#narrow/channel/205635-parley). +All public content can be read without logging in. + +## License + +Licensed under either of + +- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or ) +- MIT license ([LICENSE-MIT](LICENSE-MIT) or ) + +at your option. + +## Contribution + +Contributions are welcome by pull request. The [Rust code of conduct] applies. +Please feel free to add your name to the [AUTHORS] file in any substantive pull request. + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be licensed as above, without any additional terms or conditions. + +[Rust Code of Conduct]: https://www.rust-lang.org/policies/code-of-conduct +[AUTHORS]: ./AUTHORS diff --git a/styled_text/src/block.rs b/styled_text/src/block.rs new file mode 100644 index 00000000..a129aa60 --- /dev/null +++ b/styled_text/src/block.rs @@ -0,0 +1,40 @@ +// Copyright 2025 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use core::fmt::Debug; + +use attributed_text::TextStorage; + +use crate::text::StyledText; + +/// The kind of a [`Block`] in a [`StyledDocument`](crate::StyledDocument). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BlockKind { + /// A normal paragraph of text. + Paragraph, + /// A heading block with the given level. + Heading { + /// Heading level, typically `1..=6`. + level: u8, + }, + /// A list item block. + ListItem { + /// Whether this list item belongs to an ordered list. + ordered: bool, + }, + /// A block quote. + BlockQuote, + /// A code block. + CodeBlock, +} + +/// A semantic block in a [`StyledDocument`](crate::StyledDocument). +#[derive(Debug)] +pub struct Block { + /// The semantic kind for this block. + pub kind: BlockKind, + /// Nesting depth (for list items, quotes, etc.). + pub nesting: u16, + /// The block's styled text content. + pub text: StyledText, +} diff --git a/styled_text/src/document.rs b/styled_text/src/document.rs new file mode 100644 index 00000000..81366850 --- /dev/null +++ b/styled_text/src/document.rs @@ -0,0 +1,115 @@ +// Copyright 2025 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use alloc::vec::Vec; +use core::fmt::Debug; + +use crate::{ComputedInlineStyle, ComputedParagraphStyle}; +use attributed_text::TextStorage; + +use crate::block::Block; + +/// A flat sequence of semantic blocks. +/// +/// This is intended to carry semantic structure (headings, lists, etc.) suitable for accessibility, +/// even before layout provides geometry. +#[derive(Debug)] +pub struct StyledDocument { + root_inline: ComputedInlineStyle, + root_paragraph: ComputedParagraphStyle, + blocks: Vec>, +} + +impl Default for StyledDocument { + fn default() -> Self { + Self::new() + } +} + +impl StyledDocument { + /// Creates an empty document. + #[inline] + pub fn new() -> Self { + Self { + root_inline: ComputedInlineStyle::default(), + root_paragraph: ComputedParagraphStyle::default(), + blocks: Vec::new(), + } + } + + /// Creates an empty document with explicit root styles. + /// + /// Root styles are used for root-relative units such as `rem`. + #[inline] + pub fn new_with_root( + root_inline: ComputedInlineStyle, + root_paragraph: ComputedParagraphStyle, + ) -> Self { + Self { + root_inline, + root_paragraph, + blocks: Vec::new(), + } + } + + /// Sets the root styles for this document, updating all existing blocks. + pub fn set_root_styles( + &mut self, + root_inline: ComputedInlineStyle, + root_paragraph: ComputedParagraphStyle, + ) { + self.root_inline = root_inline; + self.root_paragraph = root_paragraph; + for block in &mut self.blocks { + block + .text + .set_root_styles(self.root_inline.clone(), self.root_paragraph.clone()); + } + } + + /// Returns the root inline style. + #[inline] + pub fn root_inline_style(&self) -> &ComputedInlineStyle { + &self.root_inline + } + + /// Returns the root paragraph style. + #[inline] + pub fn root_paragraph_style(&self) -> &ComputedParagraphStyle { + &self.root_paragraph + } + + /// Appends a block to the document. + /// + /// The document's current root styles are applied to the block. + pub fn push(&mut self, mut block: Block) { + block + .text + .set_root_styles(self.root_inline.clone(), self.root_paragraph.clone()); + self.blocks.push(block); + } + + /// Appends a block to the document without modifying its root styles. + #[inline] + pub fn push_raw(&mut self, block: Block) { + self.blocks.push(block); + } + + /// Returns an iterator over blocks. + #[inline] + pub fn iter(&self) -> impl Iterator> { + self.blocks.iter() + } + + /// Returns the number of blocks. + #[inline] + pub fn len(&self) -> usize { + self.blocks.len() + } + + /// Returns `true` if there are no blocks. + #[inline] + pub fn is_empty(&self) -> bool { + self.blocks.is_empty() + } +} diff --git a/styled_text/src/lib.rs b/styled_text/src/lib.rs new file mode 100644 index 00000000..bbb42f91 --- /dev/null +++ b/styled_text/src/lib.rs @@ -0,0 +1,139 @@ +// Copyright 2025 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Span styling and document structure built on [`attributed_text`]. +//! +//! - [`style`] defines a closed style vocabulary. +//! - [`resolve`] resolves specified styles to computed styles. +//! - [`attributed_text`] stores generic attributes on byte ranges. +//! - `styled_text` combines them: +//! - [`StyledText`]: a single layout block (maps cleanly to one Parley `Layout`) +//! - [`StyledDocument`]: a flat sequence of blocks with semantic kinds (headings, list items…) +//! +//! ## Scope +//! +//! This crate provides span application, inline style run resolution, and a lightweight block +//! model. It does not itself lower styles to Parley APIs, and it does not define paint/brush types +//! (those are expected to live in wrapper attributes and an engine-lowering layer). +//! +//! ## Design Intent +//! +//! `StyledText` is intended to be a durable attributed-text model: +//! - it can be parsed from markup and retained as an application’s in-memory representation +//! - it can be used transiently as a “layout input packet” if you already have your own model +//! - it aims to be a reasonable interchange format for rich text (for example copy/paste) +//! +//! The mutation/editing story is still evolving. Short-term APIs focus on span application and +//! layout-facing iteration; richer mutation patterns (inserts/deletes with span adjustment, etc.) +//! are expected to be added over time. +//! +//! ## Indices +//! +//! All ranges are expressed as **byte indices** into UTF-8 text, and must be on UTF-8 character +//! boundaries (as required by [`attributed_text`]). +//! +//! ## Overlaps +//! +//! When spans overlap, inline style resolution applies spans in the order they were added (last +//! writer wins). Higher-level semantic attributes can be carried by wrapper types via +//! [`HasInlineStyle`]. +//! +//! ## Example: Styled spans +//! +//! ``` +//! use styled_text::StyledText; +//! use styled_text::style::{FontSize, InlineStyle, Specified}; +//! use styled_text::resolve::{ComputedInlineStyle, ComputedParagraphStyle}; +//! +//! let base_inline = ComputedInlineStyle::default(); +//! let base_paragraph = ComputedParagraphStyle::default(); +//! let mut text = StyledText::new("Hello world!", base_inline, base_paragraph); +//! +//! // Make "world!" 1.5x larger. +//! let world = 6..12; +//! let style = InlineStyle::new().font_size(Specified::Value(FontSize::Em(1.5))); +//! text.apply_span(text.range(world).unwrap(), style); +//! +//! let runs: Vec<_> = text +//! .resolved_inline_runs_coalesced() +//! .collect(); +//! assert_eq!(runs.len(), 2); +//! assert_eq!(runs[1].range, 6..12); +//! ``` +//! +//! ## Example: Wrapper attributes for semantics +//! +//! ``` +//! # extern crate alloc; +//! use alloc::sync::Arc; +//! use styled_text::{HasInlineStyle, StyledText}; +//! use styled_text::style::InlineStyle; +//! use styled_text::resolve::{ComputedInlineStyle, ComputedParagraphStyle}; +//! +//! #[derive(Debug, Clone)] +//! struct Attr { +//! style: InlineStyle, +//! href: Option>, +//! } +//! +//! impl HasInlineStyle for Attr { +//! fn inline_style(&self) -> &InlineStyle { +//! &self.style +//! } +//! } +//! +//! let base_inline = ComputedInlineStyle::default(); +//! let base_paragraph = ComputedParagraphStyle::default(); +//! let mut text = StyledText::new("Click me", base_inline, base_paragraph); +//! text.apply_span( +//! text.range(0..8).unwrap(), +//! Attr { +//! style: InlineStyle::new(), +//! href: Some(Arc::from("https://example.invalid")), +//! }, +//! ); +//! ``` +// LINEBENDER LINT SET - lib.rs - v3 +// See https://linebender.org/wiki/canonical-lints/ +// These lints shouldn't apply to examples or tests. +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +// These lints shouldn't apply to examples. +#![warn(clippy::print_stdout, clippy::print_stderr)] +// Targeting e.g. 32-bit means structs containing usize can give false positives for 64-bit. +#![cfg_attr(target_pointer_width = "64", warn(clippy::trivially_copy_pass_by_ref))] +// END LINEBENDER LINT SET +#![cfg_attr(docsrs, feature(doc_cfg))] +#![no_std] + +extern crate alloc; + +pub mod resolve; +pub mod style; + +mod block; +mod document; +mod runs; +mod text; +mod traits; + +#[cfg(test)] +mod tests; + +pub use block::{Block, BlockKind}; +pub use document::StyledDocument; +pub use runs::{CoalescedInlineRuns, InlineStyleRun, ResolvedInlineRuns}; +pub use text::StyledText; +pub use traits::HasInlineStyle; + +pub use resolve::{ + ComputedInlineStyle, ComputedLineHeight, ComputedParagraphStyle, InlineResolveContext, + ParagraphResolveContext, ResolveStyleExt, +}; +pub use style::{ + BaseDirection, BidiControl, BidiDirection, BidiOverride, FontFamily, FontFamilyName, + FontFeature, FontFeatures, FontSize, FontStyle, FontVariation, FontVariations, FontWeight, + FontWidth, GenericFamily, InlineDeclaration, InlineStyle, LineHeight, OverflowWrap, + ParagraphDeclaration, ParagraphStyle, ParseFontFamilyError, ParseFontFamilyErrorKind, + ParseLanguageError, ParseSettingsError, ParseSettingsErrorKind, Spacing, Specified, Tag, + TextWrapMode, WordBreak, +}; diff --git a/styled_text/src/resolve/computed.rs b/styled_text/src/resolve/computed.rs new file mode 100644 index 00000000..2356a791 --- /dev/null +++ b/styled_text/src/resolve/computed.rs @@ -0,0 +1,201 @@ +// Copyright 2025 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use alloc::sync::Arc; + +use crate::style::Language; +use crate::style::{ + BaseDirection, BidiControl, FontFamily, FontFeature, FontStyle, FontVariation, FontWeight, + FontWidth, OverflowWrap, TextWrapMode, WordBreak, +}; + +/// A computed (resolved) line height. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ComputedLineHeight { + /// A multiple of the font's preferred line height (metrics-based). + MetricsRelative(f32), + /// A multiple of the font size. + FontSizeRelative(f32), + /// An absolute value in CSS pixels. + Px(f32), +} + +impl Default for ComputedLineHeight { + fn default() -> Self { + Self::MetricsRelative(1.0) + } +} + +/// A computed (resolved) inline style. +/// +/// This type is intentionally opaque: it can gain new properties over time without breaking +/// downstream code. +#[derive(Clone, Debug, PartialEq)] +pub struct ComputedInlineStyle { + pub(crate) font_family: FontFamily, + pub(crate) font_size_px: f32, + pub(crate) font_width: FontWidth, + pub(crate) font_style: FontStyle, + pub(crate) font_weight: FontWeight, + pub(crate) font_variations: Arc<[FontVariation]>, + pub(crate) font_features: Arc<[FontFeature]>, + pub(crate) locale: Option, + pub(crate) underline: bool, + pub(crate) strikethrough: bool, + pub(crate) line_height: ComputedLineHeight, + pub(crate) word_spacing_px: f32, + pub(crate) letter_spacing_px: f32, + pub(crate) bidi_control: BidiControl, +} + +impl Default for ComputedInlineStyle { + fn default() -> Self { + Self { + font_family: crate::style::GenericFamily::SansSerif.into(), + font_size_px: 16.0, + font_width: FontWidth::NORMAL, + font_style: FontStyle::default(), + font_weight: FontWeight::NORMAL, + font_variations: Arc::from([]), + font_features: Arc::from([]), + locale: None, + underline: false, + strikethrough: false, + line_height: ComputedLineHeight::default(), + word_spacing_px: 0.0, + letter_spacing_px: 0.0, + bidi_control: BidiControl::default(), + } + } +} + +impl ComputedInlineStyle { + /// Returns the computed `font-family` value. + #[inline] + pub const fn font_family(&self) -> &FontFamily { + &self.font_family + } + + /// Returns the computed font size in CSS pixels. + #[inline] + pub const fn font_size_px(&self) -> f32 { + self.font_size_px + } + + /// Returns the computed font width / stretch. + #[inline] + pub const fn font_width(&self) -> FontWidth { + self.font_width + } + + /// Returns a new style with `font-size` set to `px`. + #[inline] + pub fn with_font_size_px(mut self, px: f32) -> Self { + self.font_size_px = px; + self + } + + /// Returns the computed font style. + #[inline] + pub const fn font_style(&self) -> FontStyle { + self.font_style + } + + /// Returns the computed font weight. + #[inline] + pub const fn font_weight(&self) -> FontWeight { + self.font_weight + } + + /// Returns computed font variation settings (OpenType axis values). + #[inline] + pub fn font_variations(&self) -> &[FontVariation] { + &self.font_variations + } + + /// Returns computed font feature settings (OpenType feature values). + #[inline] + pub fn font_features(&self) -> &[FontFeature] { + &self.font_features + } + + /// Returns the locale/language tag, if any. + #[inline] + pub fn locale(&self) -> Option<&Language> { + self.locale.as_ref() + } + + /// Returns whether underline is enabled. + #[inline] + pub const fn underline(&self) -> bool { + self.underline + } + + /// Returns whether strikethrough is enabled. + #[inline] + pub const fn strikethrough(&self) -> bool { + self.strikethrough + } + + /// Returns the computed line height. + #[inline] + pub const fn line_height(&self) -> ComputedLineHeight { + self.line_height + } + + /// Returns computed extra spacing between words in CSS pixels. + #[inline] + pub const fn word_spacing_px(&self) -> f32 { + self.word_spacing_px + } + + /// Returns computed extra spacing between letters in CSS pixels. + #[inline] + pub const fn letter_spacing_px(&self) -> f32 { + self.letter_spacing_px + } + + /// Returns the computed inline bidi control. + #[inline] + pub const fn bidi_control(&self) -> BidiControl { + self.bidi_control + } +} + +/// A computed (resolved) paragraph style. +/// +/// This type is intentionally opaque: it can gain new properties over time without breaking +/// downstream code. +#[derive(Clone, Debug, PartialEq, Default)] +pub struct ComputedParagraphStyle { + pub(crate) base_direction: BaseDirection, + pub(crate) word_break: WordBreak, + pub(crate) overflow_wrap: OverflowWrap, + pub(crate) text_wrap_mode: TextWrapMode, +} + +impl ComputedParagraphStyle { + /// Returns the paragraph base direction. + #[inline] + pub const fn base_direction(&self) -> BaseDirection { + self.base_direction + } + + /// Returns `word-break`. + #[inline] + pub const fn word_break(&self) -> WordBreak { + self.word_break + } + + /// Returns `overflow-wrap`. + #[inline] + pub const fn overflow_wrap(&self) -> OverflowWrap { + self.overflow_wrap + } + + /// Returns `text-wrap-mode`. + #[inline] + pub const fn text_wrap_mode(&self) -> TextWrapMode { + self.text_wrap_mode + } +} diff --git a/styled_text/src/resolve/context.rs b/styled_text/src/resolve/context.rs new file mode 100644 index 00000000..11215226 --- /dev/null +++ b/styled_text/src/resolve/context.rs @@ -0,0 +1,98 @@ +// Copyright 2025 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use super::{ComputedInlineStyle, ComputedParagraphStyle}; + +/// Context required to resolve inline styles. +/// +/// This is a minimal context that supports: +/// - inheritance (`parent`) +/// - `initial` reset semantics +/// - root-relative units like `rem` (`root`) +/// +/// `InlineResolveContext` is intentionally a small struct with private fields so it can grow over +/// time (for example to include viewport information for `vw`/`vh` units). +#[derive(Clone, Copy, Debug)] +pub struct InlineResolveContext<'a> { + parent: &'a ComputedInlineStyle, + initial: &'a ComputedInlineStyle, + root: &'a ComputedInlineStyle, +} + +impl<'a> InlineResolveContext<'a> { + /// Creates a new resolution context. + #[inline] + pub const fn new( + parent: &'a ComputedInlineStyle, + initial: &'a ComputedInlineStyle, + root: &'a ComputedInlineStyle, + ) -> Self { + Self { + parent, + initial, + root, + } + } + + /// Returns the parent (inherited) computed style. + #[inline] + pub const fn parent(&self) -> &'a ComputedInlineStyle { + self.parent + } + + /// Returns the style used for `initial` resets. + #[inline] + pub const fn initial(&self) -> &'a ComputedInlineStyle { + self.initial + } + + /// Returns the root style used for root-relative units such as `rem`. + #[inline] + pub const fn root(&self) -> &'a ComputedInlineStyle { + self.root + } +} + +/// Context required to resolve paragraph styles. +/// +/// This mirrors [`InlineResolveContext`], but paragraph resolution is currently infallible. +#[derive(Clone, Copy, Debug)] +pub struct ParagraphResolveContext<'a> { + parent: &'a ComputedParagraphStyle, + initial: &'a ComputedParagraphStyle, + root: &'a ComputedParagraphStyle, +} + +impl<'a> ParagraphResolveContext<'a> { + /// Creates a new resolution context. + #[inline] + pub const fn new( + parent: &'a ComputedParagraphStyle, + initial: &'a ComputedParagraphStyle, + root: &'a ComputedParagraphStyle, + ) -> Self { + Self { + parent, + initial, + root, + } + } + + /// Returns the parent (inherited) computed style. + #[inline] + pub const fn parent(&self) -> &'a ComputedParagraphStyle { + self.parent + } + + /// Returns the style used for `initial` resets. + #[inline] + pub const fn initial(&self) -> &'a ComputedParagraphStyle { + self.initial + } + + /// Returns the root style used for root-relative properties. + #[inline] + pub const fn root(&self) -> &'a ComputedParagraphStyle { + self.root + } +} diff --git a/styled_text/src/resolve/engine.rs b/styled_text/src/resolve/engine.rs new file mode 100644 index 00000000..a7ec2c4a --- /dev/null +++ b/styled_text/src/resolve/engine.rs @@ -0,0 +1,256 @@ +// Copyright 2025 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use alloc::sync::Arc; + +use super::{ComputedInlineStyle, ComputedLineHeight, ComputedParagraphStyle}; +use super::{InlineResolveContext, ParagraphResolveContext}; +use crate::style::{ + BaseDirection, BidiControl, FontFamily, FontFeatures, FontSize, FontStyle, FontVariations, + FontWeight, FontWidth, InlineDeclaration, Language, LineHeight, OverflowWrap, + ParagraphDeclaration, Spacing, Specified, TextWrapMode, WordBreak, +}; +use crate::style::{FontFeature, FontVariation}; + +/// Resolves a list of inline declarations into a computed inline style. +/// +/// The input is treated as a declaration list: if multiple declarations of the same property are +/// present, the last declaration wins. +pub fn resolve_inline_declarations<'a, I>( + declarations: I, + ctx: InlineResolveContext<'_>, +) -> ComputedInlineStyle +where + I: IntoIterator, +{ + let mut font_family: Option<&Specified> = None; + let mut font_size: Option<&Specified> = None; + let mut font_style: Option<&Specified> = None; + let mut font_weight: Option<&Specified> = None; + let mut font_width: Option<&Specified> = None; + let mut font_variations: Option<&Specified> = None; + let mut font_features: Option<&Specified> = None; + let mut locale: Option<&Specified>> = None; + let mut underline: Option<&Specified> = None; + let mut strikethrough: Option<&Specified> = None; + let mut line_height: Option<&Specified> = None; + let mut word_spacing: Option<&Specified> = None; + let mut letter_spacing: Option<&Specified> = None; + let mut bidi_control: Option<&Specified> = None; + + for decl in declarations { + match decl { + InlineDeclaration::FontFamily(v) => font_family = Some(v), + InlineDeclaration::FontSize(v) => font_size = Some(v), + InlineDeclaration::FontStyle(v) => font_style = Some(v), + InlineDeclaration::FontWeight(v) => font_weight = Some(v), + InlineDeclaration::FontWidth(v) => font_width = Some(v), + InlineDeclaration::FontVariations(v) => font_variations = Some(v), + InlineDeclaration::FontFeatures(v) => font_features = Some(v), + InlineDeclaration::Locale(v) => locale = Some(v), + InlineDeclaration::Underline(v) => underline = Some(v), + InlineDeclaration::Strikethrough(v) => strikethrough = Some(v), + InlineDeclaration::LineHeight(v) => line_height = Some(v), + InlineDeclaration::WordSpacing(v) => word_spacing = Some(v), + InlineDeclaration::LetterSpacing(v) => letter_spacing = Some(v), + InlineDeclaration::BidiControl(v) => bidi_control = Some(v), + } + } + + let parent = ctx.parent(); + let initial = ctx.initial(); + let root = ctx.root(); + + let mut out = parent.clone(); + + if let Some(value) = font_family { + out.font_family = + resolve_specified(value, &parent.font_family, &initial.font_family).clone(); + } + + if let Some(value) = font_size { + out.font_size_px = match value { + Specified::Inherit => parent.font_size_px, + Specified::Initial => initial.font_size_px, + Specified::Value(v) => resolve_font_size(*v, parent.font_size_px, root.font_size_px), + }; + } + + if let Some(value) = font_style { + out.font_style = *resolve_specified(value, &parent.font_style, &initial.font_style); + } + + if let Some(value) = font_weight { + out.font_weight = *resolve_specified(value, &parent.font_weight, &initial.font_weight); + } + + if let Some(value) = font_width { + out.font_width = *resolve_specified(value, &parent.font_width, &initial.font_width); + } + + if let Some(value) = font_variations { + out.font_variations = + resolve_variations(value, &parent.font_variations, &initial.font_variations); + } + + if let Some(value) = font_features { + out.font_features = resolve_features(value, &parent.font_features, &initial.font_features); + } + + if let Some(value) = locale { + out.locale = *resolve_specified(value, &parent.locale, &initial.locale); + } + + if let Some(value) = underline { + out.underline = *resolve_specified(value, &parent.underline, &initial.underline); + } + + if let Some(value) = strikethrough { + out.strikethrough = + *resolve_specified(value, &parent.strikethrough, &initial.strikethrough); + } + + if let Some(value) = bidi_control { + out.bidi_control = *resolve_specified(value, &parent.bidi_control, &initial.bidi_control); + } + + if let Some(value) = line_height { + out.line_height = match value { + Specified::Inherit => parent.line_height, + Specified::Initial => initial.line_height, + Specified::Value(v) => resolve_line_height(*v, out.font_size_px, root.font_size_px), + }; + } + + if let Some(value) = word_spacing { + out.word_spacing_px = match value { + Specified::Inherit => parent.word_spacing_px, + Specified::Initial => initial.word_spacing_px, + Specified::Value(v) => resolve_spacing(*v, out.font_size_px, root.font_size_px), + }; + } + + if let Some(value) = letter_spacing { + out.letter_spacing_px = match value { + Specified::Inherit => parent.letter_spacing_px, + Specified::Initial => initial.letter_spacing_px, + Specified::Value(v) => resolve_spacing(*v, out.font_size_px, root.font_size_px), + }; + } + + out +} + +/// Resolves a list of paragraph declarations into a computed paragraph style. +/// +/// The input is treated as a declaration list: if multiple declarations of the same property are +/// present, the last declaration wins. +pub fn resolve_paragraph_declarations( + declarations: &[ParagraphDeclaration], + ctx: ParagraphResolveContext<'_>, +) -> ComputedParagraphStyle { + let mut base_direction: Option<&Specified> = None; + let mut word_break: Option<&Specified> = None; + let mut overflow_wrap: Option<&Specified> = None; + let mut text_wrap_mode: Option<&Specified> = None; + + for decl in declarations { + match decl { + ParagraphDeclaration::BaseDirection(v) => base_direction = Some(v), + ParagraphDeclaration::WordBreak(v) => word_break = Some(v), + ParagraphDeclaration::OverflowWrap(v) => overflow_wrap = Some(v), + ParagraphDeclaration::TextWrapMode(v) => text_wrap_mode = Some(v), + } + } + + let parent = ctx.parent(); + let initial = ctx.initial(); + + let mut out = parent.clone(); + + if let Some(value) = base_direction { + out.base_direction = + *resolve_specified(value, &parent.base_direction, &initial.base_direction); + } + if let Some(value) = word_break { + out.word_break = *resolve_specified(value, &parent.word_break, &initial.word_break); + } + if let Some(value) = overflow_wrap { + out.overflow_wrap = + *resolve_specified(value, &parent.overflow_wrap, &initial.overflow_wrap); + } + if let Some(value) = text_wrap_mode { + out.text_wrap_mode = + *resolve_specified(value, &parent.text_wrap_mode, &initial.text_wrap_mode); + } + + out +} + +#[inline] +fn resolve_specified<'a, T>(specified: &'a Specified, parent: &'a T, initial: &'a T) -> &'a T { + match specified { + Specified::Inherit => parent, + Specified::Initial => initial, + Specified::Value(value) => value, + } +} + +#[inline] +fn resolve_font_size(specified: FontSize, parent_font_size_px: f32, root_font_size_px: f32) -> f32 { + match specified { + FontSize::Px(px) => px, + FontSize::Em(em) => parent_font_size_px * em, + FontSize::Rem(rem) => root_font_size_px * rem, + } +} + +#[inline] +fn resolve_spacing(specified: Spacing, font_size_px: f32, root_font_size_px: f32) -> f32 { + match specified { + Spacing::Px(px) => px, + Spacing::Em(em) => font_size_px * em, + Spacing::Rem(rem) => root_font_size_px * rem, + } +} + +#[inline] +fn resolve_line_height( + specified: LineHeight, + font_size_px: f32, + root_font_size_px: f32, +) -> ComputedLineHeight { + match specified { + LineHeight::Normal => ComputedLineHeight::MetricsRelative(1.0), + LineHeight::Factor(f) => ComputedLineHeight::FontSizeRelative(f), + LineHeight::Px(px) => ComputedLineHeight::Px(px), + LineHeight::Em(em) => ComputedLineHeight::Px(font_size_px * em), + LineHeight::Rem(rem) => ComputedLineHeight::Px(root_font_size_px * rem), + } +} + +#[inline] +fn resolve_variations( + specified: &Specified, + parent: &Arc<[FontVariation]>, + initial: &Arc<[FontVariation]>, +) -> Arc<[FontVariation]> { + match specified { + Specified::Inherit => Arc::clone(parent), + Specified::Initial => Arc::clone(initial), + Specified::Value(value) => Arc::clone(value.as_arc_slice()), + } +} + +#[inline] +fn resolve_features( + specified: &Specified, + parent: &Arc<[FontFeature]>, + initial: &Arc<[FontFeature]>, +) -> Arc<[FontFeature]> { + match specified { + Specified::Inherit => Arc::clone(parent), + Specified::Initial => Arc::clone(initial), + Specified::Value(value) => Arc::clone(value.as_arc_slice()), + } +} diff --git a/styled_text/src/resolve/mod.rs b/styled_text/src/resolve/mod.rs new file mode 100644 index 00000000..dec74426 --- /dev/null +++ b/styled_text/src/resolve/mod.rs @@ -0,0 +1,60 @@ +// Copyright 2025 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Specified→computed resolution for [`style`](crate::style). +//! +//! [`style`](crate::style) intentionally focuses on a lightweight, shareable style vocabulary: +//! declarations, specified values, and common value types. +//! +//! This module provides the “engine” layer: +//! - Computed style types ([`ComputedInlineStyle`], [`ComputedParagraphStyle`]) +//! - Resolution contexts ([`InlineResolveContext`], [`ParagraphResolveContext`]) +//! - Specified→computed resolution +//! +//! It is `no_std` + `alloc` friendly. + +mod computed; +mod context; +mod engine; + +#[cfg(test)] +mod tests; + +pub use computed::{ComputedInlineStyle, ComputedLineHeight, ComputedParagraphStyle}; +pub use context::{InlineResolveContext, ParagraphResolveContext}; +pub use engine::{resolve_inline_declarations, resolve_paragraph_declarations}; + +use crate::style::{InlineStyle, ParagraphStyle}; + +/// Extension trait that adds resolution helpers to [`style`](crate::style) types. +pub trait ResolveStyleExt { + /// The computed result type. + type Computed; + /// The resolution context type. + type Context<'a> + where + Self: 'a; + + /// Resolves this style relative to the provided context. + fn resolve(&self, ctx: Self::Context<'_>) -> Self::Computed; +} + +impl ResolveStyleExt for InlineStyle { + type Computed = ComputedInlineStyle; + type Context<'a> = InlineResolveContext<'a>; + + #[inline] + fn resolve(&self, ctx: Self::Context<'_>) -> Self::Computed { + resolve_inline_declarations(self.declarations(), ctx) + } +} + +impl ResolveStyleExt for ParagraphStyle { + type Computed = ComputedParagraphStyle; + type Context<'a> = ParagraphResolveContext<'a>; + + #[inline] + fn resolve(&self, ctx: Self::Context<'_>) -> Self::Computed { + resolve_paragraph_declarations(self.declarations(), ctx) + } +} diff --git a/styled_text/src/resolve/tests.rs b/styled_text/src/resolve/tests.rs new file mode 100644 index 00000000..00c64005 --- /dev/null +++ b/styled_text/src/resolve/tests.rs @@ -0,0 +1,215 @@ +// Copyright 2025 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +extern crate alloc; + +use alloc::boxed::Box; +use alloc::sync::Arc; + +use crate::resolve::{ + ComputedInlineStyle, ComputedLineHeight, ComputedParagraphStyle, InlineResolveContext, + ParagraphResolveContext, ResolveStyleExt, +}; +use crate::style::{ + BaseDirection, FontFeature, FontFeatures, FontSize, FontVariation, FontVariations, + InlineDeclaration, InlineStyle, LineHeight, ParagraphStyle, Spacing, Specified, Tag, WordBreak, +}; + +#[test] +fn specified_inherit_initial_and_value() { + let parent = ComputedInlineStyle::default().with_font_size_px(20.0); + let initial = ComputedInlineStyle::default().with_font_size_px(10.0); + let root = ComputedInlineStyle::default().with_font_size_px(8.0); + + let ctx = InlineResolveContext::new(&parent, &initial, &root); + let computed = InlineStyle::new() + .font_size(Specified::Inherit) + .resolve(ctx); + assert_eq!(computed.font_size_px(), 20.0); + + let computed = InlineStyle::new() + .font_size(Specified::Initial) + .resolve(ctx); + assert_eq!(computed.font_size_px(), 10.0); + + let computed = InlineStyle::new() + .font_size(Specified::Value(FontSize::Em(2.0))) + .resolve(ctx); + assert_eq!(computed.font_size_px(), 40.0); + + let computed = InlineStyle::new() + .font_size(Specified::Value(FontSize::Rem(2.0))) + .resolve(ctx); + assert_eq!(computed.font_size_px(), 16.0); +} + +#[test] +fn em_values_resolve_against_computed_font_size() { + let base = ComputedInlineStyle::default(); + + // letter-spacing depends on computed font size, regardless of declaration order. + let style = InlineStyle::new() + .letter_spacing(Specified::Value(Spacing::Em(0.5))) + .font_size(Specified::Value(FontSize::Px(20.0))); + let ctx = InlineResolveContext::new(&base, &base, &base); + let computed = style.resolve(ctx); + assert_eq!(computed.font_size_px(), 20.0); + assert_eq!(computed.letter_spacing_px(), 10.0); +} + +#[test] +fn paragraph_base_direction_resolves() { + let base = ComputedParagraphStyle::default(); + let style = ParagraphStyle::new().base_direction(Specified::Value(BaseDirection::Rtl)); + let ctx = ParagraphResolveContext::new(&base, &base, &base); + let computed = style.resolve(ctx); + assert_eq!(computed.base_direction(), BaseDirection::Rtl); +} + +#[test] +fn last_declaration_wins_within_a_style() { + let base = ComputedInlineStyle::default(); + let ctx = InlineResolveContext::new(&base, &base, &base); + + let style = InlineStyle::new() + .font_size(Specified::Value(FontSize::Px(10.0))) + .font_size(Specified::Value(FontSize::Px(20.0))); + let computed = style.resolve(ctx); + assert_eq!(computed.font_size_px(), 20.0); + + let style = InlineStyle::new() + .letter_spacing(Specified::Value(Spacing::Em(1.0))) + .letter_spacing(Specified::Value(Spacing::Px(3.0))); + let computed = style.resolve(ctx); + assert_eq!(computed.letter_spacing_px(), 3.0); +} + +#[test] +fn from_declarations_builds_style_in_order() { + let base = ComputedInlineStyle::default(); + let ctx = InlineResolveContext::new(&base, &base, &base); + + let style = InlineStyle::from_declarations([ + InlineDeclaration::FontSize(Specified::Value(FontSize::Px(10.0))), + InlineDeclaration::FontSize(Specified::Value(FontSize::Px(20.0))), + ]); + let computed = style.resolve(ctx); + assert_eq!(computed.font_size_px(), 20.0); +} + +#[test] +fn rem_resolves_against_root_font_size() { + let parent = ComputedInlineStyle::default(); + let root = ComputedInlineStyle::default().with_font_size_px(10.0); + + let initial = parent.clone(); + let ctx = InlineResolveContext::new(&parent, &initial, &root); + let style = InlineStyle::new().font_size(Specified::Value(FontSize::Rem(2.0))); + let computed = style.resolve(ctx); + assert_eq!(computed.font_size_px(), 20.0); +} + +#[test] +fn spacing_rem_resolves_against_root_font_size() { + let parent = ComputedInlineStyle::default(); + let root = ComputedInlineStyle::default().with_font_size_px(10.0); + let initial = parent.clone(); + + let ctx = InlineResolveContext::new(&parent, &initial, &root); + let style = InlineStyle::new().letter_spacing(Specified::Value(Spacing::Rem(1.5))); + let computed = style.resolve(ctx); + assert_eq!(computed.letter_spacing_px(), 15.0); +} + +#[test] +fn line_height_rem_resolves_against_root_font_size() { + let parent = ComputedInlineStyle::default(); + let root = ComputedInlineStyle::default().with_font_size_px(10.0); + let initial = parent.clone(); + + let ctx = InlineResolveContext::new(&parent, &initial, &root); + let style = InlineStyle::new().line_height(Specified::Value(LineHeight::Rem(2.0))); + let computed = style.resolve(ctx); + assert_eq!(computed.line_height(), ComputedLineHeight::Px(20.0)); +} + +#[test] +fn em_spacing_uses_final_computed_font_size_when_font_size_is_rem() { + let parent = ComputedInlineStyle::default(); + let root = ComputedInlineStyle::default().with_font_size_px(10.0); + let initial = parent.clone(); + let ctx = InlineResolveContext::new(&parent, &initial, &root); + + // font-size resolves to 20px, so 0.5em should become 10px. + let style = InlineStyle::new() + .font_size(Specified::Value(FontSize::Rem(2.0))) + .letter_spacing(Specified::Value(Spacing::Em(0.5))); + let computed = style.resolve(ctx); + assert_eq!(computed.font_size_px(), 20.0); + assert_eq!(computed.letter_spacing_px(), 10.0); +} + +#[test] +fn paragraph_inherit_and_initial() { + let parent = ComputedParagraphStyle { + word_break: WordBreak::KeepAll, + ..ComputedParagraphStyle::default() + }; + let initial = ComputedParagraphStyle::default(); + let ctx = ParagraphResolveContext::new(&parent, &initial, &initial); + + let computed = ParagraphStyle::new() + .word_break(Specified::Inherit) + .resolve(ctx); + assert_eq!(computed.word_break(), WordBreak::KeepAll); + + let computed = ParagraphStyle::new() + .word_break(Specified::Initial) + .resolve(ctx); + assert_eq!(computed.word_break(), WordBreak::Normal); +} + +#[test] +fn variation_settings_apply_from_parsed_list() { + let base = ComputedInlineStyle::default(); + let ctx = InlineResolveContext::new(&base, &base, &base); + let parsed = FontVariation::parse_css_list("\"wght\" 700, \"wdth\" 120") + .collect::, _>>() + .unwrap(); + let variations = FontVariations::list(Arc::from(parsed)); + let style = InlineStyle::new().font_variations(Specified::Value(variations)); + + let computed = style.resolve(ctx); + assert_eq!(computed.font_variations().len(), 2); + assert_eq!( + computed.font_variations()[0], + FontVariation::new(Tag::new(b"wght"), 700.0) + ); +} + +#[test] +fn feature_settings_apply_from_list() { + let base = ComputedInlineStyle::default(); + let ctx = InlineResolveContext::new(&base, &base, &base); + let features = FontFeatures::list(Arc::from([ + FontFeature::new(Tag::new(b"liga"), 1), + FontFeature::new(Tag::new(b"kern"), 0), + FontFeature::new(Tag::new(b"calt"), 1), + ])); + let style = InlineStyle::new().font_features(Specified::Value(features)); + + let computed = style.resolve(ctx); + assert_eq!(computed.font_features().len(), 3); + assert_eq!( + computed.font_features()[0], + FontFeature::new(Tag::new(b"liga"), 1) + ); + assert_eq!( + computed.font_features()[1], + FontFeature::new(Tag::new(b"kern"), 0) + ); + assert_eq!( + computed.font_features()[2], + FontFeature::new(Tag::new(b"calt"), 1) + ); +} diff --git a/styled_text/src/runs.rs b/styled_text/src/runs.rs new file mode 100644 index 00000000..801b7799 --- /dev/null +++ b/styled_text/src/runs.rs @@ -0,0 +1,269 @@ +// Copyright 2025 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use alloc::vec; +use alloc::vec::Vec; +use core::fmt::Debug; +use core::ops::Range; + +use crate::resolve::resolve_inline_declarations; +use crate::{ComputedInlineStyle, InlineDeclaration, InlineResolveContext}; +use attributed_text::TextStorage; + +use crate::text::StyledText; +use crate::traits::HasInlineStyle; + +#[derive(Clone, Debug)] +struct Span<'a> { + declarations: &'a [InlineDeclaration], +} + +const INLINE_DECL_KEY_COUNT: usize = 14; + +#[inline] +fn inline_decl_key_index(decl: &InlineDeclaration) -> usize { + match decl { + InlineDeclaration::FontFamily(_) => 0, + InlineDeclaration::FontSize(_) => 1, + InlineDeclaration::FontStyle(_) => 2, + InlineDeclaration::FontWeight(_) => 3, + InlineDeclaration::FontWidth(_) => 4, + InlineDeclaration::FontVariations(_) => 5, + InlineDeclaration::FontFeatures(_) => 6, + InlineDeclaration::Locale(_) => 7, + InlineDeclaration::Underline(_) => 8, + InlineDeclaration::Strikethrough(_) => 9, + InlineDeclaration::LineHeight(_) => 10, + InlineDeclaration::WordSpacing(_) => 11, + InlineDeclaration::LetterSpacing(_) => 12, + InlineDeclaration::BidiControl(_) => 13, + } +} + +/// A resolved inline style run for a contiguous text range. +#[derive(Clone, Debug, PartialEq)] +pub struct InlineStyleRun { + /// The byte range in the underlying text. + pub range: Range, + /// The computed inline style for this range. + pub style: ComputedInlineStyle, +} + +/// An iterator over resolved inline style runs. +#[derive(Clone, Debug)] +pub struct ResolvedInlineRuns<'a, T: Debug + TextStorage, A: Debug + HasInlineStyle> { + pub(crate) styled: &'a StyledText, + pub(crate) boundaries: Vec, + start_offsets: Vec, + start_events: Vec, + end_offsets: Vec, + end_events: Vec, + spans: Vec>, + active: Vec, + pub(crate) index: usize, +} + +impl<'a, T: Debug + TextStorage, A: Debug + HasInlineStyle> ResolvedInlineRuns<'a, T, A> { + pub(crate) fn new(styled: &'a StyledText) -> Self { + let len = styled.attributed.len(); + let attr_count = styled.attributed.attributes_len(); + // Each attribute can contribute up to two boundaries (start/end), plus the implicit 0/len. + let mut boundaries = Vec::with_capacity(2 + attr_count.saturating_mul(2)); + boundaries.push(0); + boundaries.push(len); + for (range, _) in styled.attributed.attributes_iter() { + boundaries.push(range.start); + boundaries.push(range.end); + } + boundaries.sort_unstable(); + boundaries.dedup(); + + let boundary_count = boundaries.len(); + + let mut spans = Vec::with_capacity(attr_count); + let mut span_boundaries = Vec::with_capacity(attr_count); + + // We build start/end event lists keyed by boundary index. Instead of a `Vec>` + // (which would allocate an inner `Vec` for each boundary), we use a + // CSR (Compressed Sparse Row)-style layout: + // a single flat event buffer plus an offsets array giving the slice for each boundary. + // + // This represents "many small lists" without lots of tiny heap allocations. + // + // We store each span's (start_boundary, end_boundary) indices to avoid allocating + // separate `span_starts`/`span_ends` arrays. + let mut start_counts = vec![0_usize; boundary_count]; + let mut end_counts = vec![0_usize; boundary_count]; + for (range, attr) in styled.attributed.attributes_iter() { + if range.start == range.end { + continue; + } + let start_boundary = boundaries + .binary_search(&range.start) + .expect("attribute boundary start should be in boundary list"); + let end_boundary = boundaries + .binary_search(&range.end) + .expect("attribute boundary end should be in boundary list"); + if start_boundary == end_boundary { + continue; + } + + spans.push(Span { + declarations: attr.inline_style().declarations(), + }); + span_boundaries.push((start_boundary, end_boundary)); + start_counts[start_boundary] += 1; + end_counts[end_boundary] += 1; + } + + let mut start_offsets = vec![0_usize; boundary_count + 1]; + let mut end_offsets = vec![0_usize; boundary_count + 1]; + for i in 0..boundary_count { + start_offsets[i + 1] = start_offsets[i] + start_counts[i]; + end_offsets[i + 1] = end_offsets[i] + end_counts[i]; + } + + let mut start_events = vec![0_usize; start_offsets[boundary_count]]; + let mut end_events = vec![0_usize; end_offsets[boundary_count]]; + + // Reuse `*_counts` as per-boundary write cursors to fill the CSR event buffers without + // allocating additional `*_next` arrays. + start_counts.fill(0); + end_counts.fill(0); + for (id, (start_boundary, end_boundary)) in span_boundaries.iter().copied().enumerate() { + let start_ix = start_offsets[start_boundary] + start_counts[start_boundary]; + start_events[start_ix] = id; + start_counts[start_boundary] += 1; + + let end_ix = end_offsets[end_boundary] + end_counts[end_boundary]; + end_events[end_ix] = id; + end_counts[end_boundary] += 1; + } + + let span_len = spans.len(); + Self { + styled, + boundaries, + start_offsets, + start_events, + end_offsets, + end_events, + spans, + // In the worst case, all spans could overlap a single boundary segment. + active: Vec::with_capacity(span_len), + index: 0, + } + } + + fn update_active_for_boundary(&mut self, boundary_index: usize) { + let end_range = self.end_offsets[boundary_index]..self.end_offsets[boundary_index + 1]; + for &id in &self.end_events[end_range] { + if let Ok(ix) = self.active.binary_search(&id) { + self.active.remove(ix); + } + } + let start_range = + self.start_offsets[boundary_index]..self.start_offsets[boundary_index + 1]; + for &id in &self.start_events[start_range] { + match self.active.binary_search(&id) { + Ok(_) => {} + Err(ix) => self.active.insert(ix, id), + } + } + } + + fn compute_style_for_current_segment(&mut self) -> ComputedInlineStyle { + let mut picked: [Option<&InlineDeclaration>; INLINE_DECL_KEY_COUNT] = + core::array::from_fn(|_| None); + let mut remaining = INLINE_DECL_KEY_COUNT; + + for &span_id in self.active.iter().rev() { + let span = &self.spans[span_id]; + for decl in span.declarations.iter().rev() { + let idx = inline_decl_key_index(decl); + if picked[idx].is_some() { + continue; + } + picked[idx] = Some(decl); + remaining -= 1; + if remaining == 0 { + break; + } + } + if remaining == 0 { + break; + } + } + + resolve_inline_declarations( + picked.into_iter().flatten(), + InlineResolveContext::new( + &self.styled.base_inline, + &self.styled.initial_inline, + &self.styled.root_inline, + ), + ) + } +} + +impl Iterator for ResolvedInlineRuns<'_, T, A> { + type Item = InlineStyleRun; + + fn next(&mut self) -> Option { + while self.index + 1 < self.boundaries.len() { + self.update_active_for_boundary(self.index); + let start = self.boundaries[self.index]; + let end = self.boundaries[self.index + 1]; + self.index += 1; + if start == end { + continue; + } + + return Some(InlineStyleRun { + range: start..end, + style: self.compute_style_for_current_segment(), + }); + } + None + } +} + +/// An iterator over coalesced resolved inline style runs. +#[derive(Clone, Debug)] +pub struct CoalescedInlineRuns<'a, T: Debug + TextStorage, A: Debug + HasInlineStyle> { + pub(crate) inner: ResolvedInlineRuns<'a, T, A>, + pending: Option, +} + +impl<'a, T: Debug + TextStorage, A: Debug + HasInlineStyle> CoalescedInlineRuns<'a, T, A> { + pub(crate) fn new(styled: &'a StyledText) -> Self { + Self { + inner: ResolvedInlineRuns::new(styled), + pending: None, + } + } +} + +impl Iterator for CoalescedInlineRuns<'_, T, A> { + type Item = InlineStyleRun; + + fn next(&mut self) -> Option { + let mut run = self.pending.take().or_else(|| self.inner.next())?; + + loop { + match self.inner.next() { + None => break, + Some(next_run) => { + if next_run.range.start == run.range.end && next_run.style == run.style { + run.range.end = next_run.range.end; + continue; + } + self.pending = Some(next_run); + break; + } + } + } + + Some(run) + } +} diff --git a/styled_text/src/style/declarations.rs b/styled_text/src/style/declarations.rs new file mode 100644 index 00000000..b1c1daec --- /dev/null +++ b/styled_text/src/style/declarations.rs @@ -0,0 +1,410 @@ +// Copyright 2025 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use alloc::vec::Vec; + +use super::Language; +use super::specified::Specified; +use super::values::{FontSize, FontStyle, LineHeight, Spacing}; +use super::{BaseDirection, BidiControl, FontFamily, FontFeatures, FontVariations, FontWidth}; +use super::{FontWeight as FontWeightValue, OverflowWrap, TextWrapMode, WordBreak}; + +/// A single inline style declaration. +#[derive(Clone, Debug, PartialEq)] +pub enum InlineDeclaration { + /// CSS `font-family`. + FontFamily(Specified), + /// Font size. + FontSize(Specified), + /// Font style. + FontStyle(Specified), + /// Font weight. + FontWeight(Specified), + /// Font width / stretch. + FontWidth(Specified), + /// Font variation settings (OpenType axis values). + FontVariations(Specified), + /// Font feature settings (OpenType feature values). + FontFeatures(Specified), + /// Locale/language tag, if any. + Locale(Specified>), + /// Underline decoration. + Underline(Specified), + /// Strikethrough decoration. + Strikethrough(Specified), + /// Line height. + LineHeight(Specified), + /// Extra spacing between words. + WordSpacing(Specified), + /// Extra spacing between letters. + LetterSpacing(Specified), + /// Inline bidi control. + BidiControl(Specified), +} + +/// A set of specified inline declarations for a span. +/// +/// This is a declaration list (not a “one field per property” struct). When multiple declarations +/// of the same property are present, the last declaration wins. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct InlineStyle { + declarations: Vec, +} + +impl InlineStyle { + /// Creates an empty style (no declarations). + #[inline] + pub fn new() -> Self { + Self::default() + } + + /// Creates an empty style with capacity for `capacity` declarations. + /// + /// This is useful when building styles programmatically and you have a good idea of how many + /// declarations you will add. + #[inline] + pub fn with_capacity(capacity: usize) -> Self { + Self { + declarations: Vec::with_capacity(capacity), + } + } + + /// Creates a style from an iterator of declarations. + /// + /// This is a convenience for building styles without chaining many setter calls. + /// + /// ## Example + /// + /// ``` + /// use styled_text::style::{FontSize, InlineDeclaration, InlineStyle, Specified}; + /// + /// let style = InlineStyle::from_declarations([ + /// InlineDeclaration::FontSize(Specified::Value(FontSize::Px(16.0))), + /// InlineDeclaration::Underline(Specified::Value(true)), + /// ]); + /// assert_eq!(style.declarations().len(), 2); + /// ``` + #[inline] + pub fn from_declarations(declarations: I) -> Self + where + I: IntoIterator, + { + Self { + declarations: declarations.into_iter().collect(), + } + } + + /// Returns the declarations in this style, in authoring order. + #[inline] + pub fn declarations(&self) -> &[InlineDeclaration] { + &self.declarations + } + + /// Removes all declarations from this style, retaining the allocated storage. + /// + /// This is useful for reusing an `InlineStyle` as scratch storage when generating many + /// computed runs. + /// + /// ## Example + /// + /// ``` + /// use styled_text::style::{InlineDeclaration, InlineStyle, Specified}; + /// + /// let mut style = InlineStyle::new().underline(Specified::Value(true)); + /// assert_eq!(style.declarations().len(), 1); + /// + /// style.clear(); + /// assert!(style.declarations().is_empty()); + /// + /// style.push_declaration(InlineDeclaration::Underline(Specified::Value(false))); + /// assert_eq!(style.declarations().len(), 1); + /// ``` + #[inline] + pub fn clear(&mut self) { + self.declarations.clear(); + } + + /// Appends a declaration to this style. + #[inline] + pub fn push_declaration(&mut self, declaration: InlineDeclaration) { + self.declarations.push(declaration); + } + + /// Appends an arbitrary declaration. + #[inline] + pub fn push(mut self, declaration: InlineDeclaration) -> Self { + self.declarations.push(declaration); + self + } + + /// Sets `font-family`. + #[inline] + pub fn font_family(self, value: Specified) -> Self { + self.push(InlineDeclaration::FontFamily(value)) + } + + /// Sets `font-family` to a concrete value. + #[inline] + pub fn with_font_family(self, value: FontFamily) -> Self { + self.font_family(Specified::Value(value)) + } + + /// Sets `font-family: inherit`. + #[inline] + pub fn inherit_font_family(self) -> Self { + self.font_family(Specified::Inherit) + } + + /// Sets `font-family: initial`. + #[inline] + pub fn initial_font_family(self) -> Self { + self.font_family(Specified::Initial) + } + + /// Sets `font-size`. + #[inline] + pub fn font_size(self, value: Specified) -> Self { + self.push(InlineDeclaration::FontSize(value)) + } + + /// Sets `font-size` to a concrete value. + #[inline] + pub fn with_font_size(self, value: FontSize) -> Self { + self.font_size(Specified::Value(value)) + } + + /// Sets `font-size: inherit`. + #[inline] + pub fn inherit_font_size(self) -> Self { + self.font_size(Specified::Inherit) + } + + /// Sets `font-size: initial`. + #[inline] + pub fn initial_font_size(self) -> Self { + self.font_size(Specified::Initial) + } + + /// Sets `font-style`. + #[inline] + pub fn font_style(self, value: Specified) -> Self { + self.push(InlineDeclaration::FontStyle(value)) + } + + /// Sets `font-style` to a concrete value. + #[inline] + pub fn with_font_style(self, value: FontStyle) -> Self { + self.font_style(Specified::Value(value)) + } + + /// Sets `font-style: inherit`. + #[inline] + pub fn inherit_font_style(self) -> Self { + self.font_style(Specified::Inherit) + } + + /// Sets `font-style: initial`. + #[inline] + pub fn initial_font_style(self) -> Self { + self.font_style(Specified::Initial) + } + + /// Sets `font-weight`. + #[inline] + pub fn font_weight(self, value: Specified) -> Self { + self.push(InlineDeclaration::FontWeight(value)) + } + + /// Sets `font-weight` to a concrete value. + #[inline] + pub fn with_font_weight(self, value: FontWeightValue) -> Self { + self.font_weight(Specified::Value(value)) + } + + /// Sets `font-weight: inherit`. + #[inline] + pub fn inherit_font_weight(self) -> Self { + self.font_weight(Specified::Inherit) + } + + /// Sets `font-weight: initial`. + #[inline] + pub fn initial_font_weight(self) -> Self { + self.font_weight(Specified::Initial) + } + + /// Sets `font-width` / stretch. + #[inline] + pub fn font_width(self, value: Specified) -> Self { + self.push(InlineDeclaration::FontWidth(value)) + } + + /// Sets `font-width` to a concrete value. + #[inline] + pub fn with_font_width(self, value: FontWidth) -> Self { + self.font_width(Specified::Value(value)) + } + + /// Sets `font-width: inherit`. + #[inline] + pub fn inherit_font_width(self) -> Self { + self.font_width(Specified::Inherit) + } + + /// Sets `font-width: initial`. + #[inline] + pub fn initial_font_width(self) -> Self { + self.font_width(Specified::Initial) + } + + /// Sets font variation settings. + #[inline] + pub fn font_variations(self, value: Specified) -> Self { + self.push(InlineDeclaration::FontVariations(value)) + } + + /// Sets font variation settings to a concrete value. + #[inline] + pub fn with_font_variations(self, value: FontVariations) -> Self { + self.font_variations(Specified::Value(value)) + } + + /// Sets font variation settings to `inherit`. + #[inline] + pub fn inherit_font_variations(self) -> Self { + self.font_variations(Specified::Inherit) + } + + /// Sets font variation settings to `initial`. + #[inline] + pub fn initial_font_variations(self) -> Self { + self.font_variations(Specified::Initial) + } + + /// Sets font feature settings. + #[inline] + pub fn font_features(self, value: Specified) -> Self { + self.push(InlineDeclaration::FontFeatures(value)) + } + + /// Sets font feature settings to a concrete value. + #[inline] + pub fn with_font_features(self, value: FontFeatures) -> Self { + self.font_features(Specified::Value(value)) + } + + /// Sets font feature settings to `inherit`. + #[inline] + pub fn inherit_font_features(self) -> Self { + self.font_features(Specified::Inherit) + } + + /// Sets font feature settings to `initial`. + #[inline] + pub fn initial_font_features(self) -> Self { + self.font_features(Specified::Initial) + } + + /// Sets `locale` (language tag), if any. + #[inline] + pub fn locale(self, value: Specified>) -> Self { + self.push(InlineDeclaration::Locale(value)) + } + + /// Sets `locale` (language tag) to a concrete value. + #[inline] + pub fn with_locale(self, value: Language) -> Self { + self.locale(Specified::Value(Some(value))) + } + + /// Clears `locale` (language tag). + #[inline] + pub fn without_locale(self) -> Self { + self.locale(Specified::Value(None)) + } + + /// Sets `text-decoration-line: underline`. + #[inline] + pub fn underline(self, value: Specified) -> Self { + self.push(InlineDeclaration::Underline(value)) + } + + /// Sets `text-decoration-line: underline` to a concrete value. + #[inline] + pub fn with_underline(self, value: bool) -> Self { + self.underline(Specified::Value(value)) + } + + /// Sets `text-decoration-line: underline` to `inherit`. + #[inline] + pub fn inherit_underline(self) -> Self { + self.underline(Specified::Inherit) + } + + /// Sets `text-decoration-line: underline` to `initial`. + #[inline] + pub fn initial_underline(self) -> Self { + self.underline(Specified::Initial) + } + + /// Sets `text-decoration-line: line-through`. + #[inline] + pub fn strikethrough(self, value: Specified) -> Self { + self.push(InlineDeclaration::Strikethrough(value)) + } + + /// Sets `text-decoration-line: line-through` to a concrete value. + #[inline] + pub fn with_strikethrough(self, value: bool) -> Self { + self.strikethrough(Specified::Value(value)) + } + + /// Sets `text-decoration-line: line-through` to `inherit`. + #[inline] + pub fn inherit_strikethrough(self) -> Self { + self.strikethrough(Specified::Inherit) + } + + /// Sets `text-decoration-line: line-through` to `initial`. + #[inline] + pub fn initial_strikethrough(self) -> Self { + self.strikethrough(Specified::Initial) + } + + /// Sets `line-height`. + #[inline] + pub fn line_height(self, value: Specified) -> Self { + self.push(InlineDeclaration::LineHeight(value)) + } + + /// Sets `word-spacing`. + #[inline] + pub fn word_spacing(self, value: Specified) -> Self { + self.push(InlineDeclaration::WordSpacing(value)) + } + + /// Sets `letter-spacing`. + #[inline] + pub fn letter_spacing(self, value: Specified) -> Self { + self.push(InlineDeclaration::LetterSpacing(value)) + } + + /// Sets bidi controls for this span. + #[inline] + pub fn bidi_control(self, value: Specified) -> Self { + self.push(InlineDeclaration::BidiControl(value)) + } +} + +/// A single paragraph style declaration. +#[derive(Clone, Debug, PartialEq)] +pub enum ParagraphDeclaration { + /// The paragraph's base direction. + BaseDirection(Specified), + /// Control over where words can wrap. + WordBreak(Specified), + /// Control over "emergency" line-breaking. + OverflowWrap(Specified), + /// Control over non-"emergency" line-breaking. + TextWrapMode(Specified), +} diff --git a/styled_text/src/style/mod.rs b/styled_text/src/style/mod.rs new file mode 100644 index 00000000..d3e369f1 --- /dev/null +++ b/styled_text/src/style/mod.rs @@ -0,0 +1,41 @@ +// Copyright 2025 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! CSS-inspired text style vocabulary. +//! +//! This module defines: +//! - A closed set of inline and paragraph style properties (the vocabulary) +//! - CSS-like reset semantics via [`Specified`] +//! +//! It is intentionally independent of any shaping/layout engine. +//! +//! Specified→computed resolution lives in [`resolve`](crate::resolve). + +mod declarations; +mod paragraph; +mod settings; +mod specified; +mod values; + +pub use declarations::{InlineDeclaration, InlineStyle, ParagraphDeclaration}; +pub use paragraph::{BaseDirection, OverflowWrap, ParagraphStyle, TextWrapMode, WordBreak}; +pub use settings::{FontFeature, FontFeatures, FontVariation, FontVariations, Tag}; +pub use specified::Specified; +pub use text_primitives::{BidiControl, BidiDirection, BidiOverride}; +pub use text_primitives::{FontWeight, FontWidth}; +pub use text_primitives::{GenericFamily, ParseFontFamilyError, ParseFontFamilyErrorKind}; +pub use text_primitives::{Language, ParseLanguageError}; +pub use text_primitives::{ParseSettingsError, ParseSettingsErrorKind}; +pub use values::{FontSize, FontStyle, LineHeight, Spacing}; + +/// Owned CSS `font-family` property value. +/// +/// This is the owned form of [`text_primitives::FontFamily`]. The `'static` lifetime indicates +/// that the value does not borrow from an external string slice. +pub type FontFamily = text_primitives::FontFamily<'static>; + +/// Owned font family name or generic family. +/// +/// This is the owned form of [`text_primitives::FontFamilyName`]. The `'static` lifetime +/// indicates that the value does not borrow from an external string slice. +pub type FontFamilyName = text_primitives::FontFamilyName<'static>; diff --git a/styled_text/src/style/paragraph.rs b/styled_text/src/style/paragraph.rs new file mode 100644 index 00000000..74718f22 --- /dev/null +++ b/styled_text/src/style/paragraph.rs @@ -0,0 +1,66 @@ +// Copyright 2025 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use alloc::vec::Vec; + +use super::declarations::ParagraphDeclaration; +use super::specified::Specified; + +pub use text_primitives::{BaseDirection, OverflowWrap, TextWrapMode, WordBreak}; + +/// A set of specified paragraph declarations for a block of text. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ParagraphStyle { + declarations: Vec, +} + +impl ParagraphStyle { + /// Creates an empty paragraph style (no declarations). + #[inline] + pub fn new() -> Self { + Self::default() + } + + /// Returns the declarations in this style, in authoring order. + #[inline] + pub fn declarations(&self) -> &[ParagraphDeclaration] { + &self.declarations + } + + /// Removes all declarations from this style, retaining the allocated storage. + #[inline] + pub fn clear(&mut self) { + self.declarations.clear(); + } + + /// Appends an arbitrary declaration. + #[inline] + pub fn push(mut self, declaration: ParagraphDeclaration) -> Self { + self.declarations.push(declaration); + self + } + + /// Sets the base direction. + #[inline] + pub fn base_direction(self, value: Specified) -> Self { + self.push(ParagraphDeclaration::BaseDirection(value)) + } + + /// Sets `word-break`. + #[inline] + pub fn word_break(self, value: Specified) -> Self { + self.push(ParagraphDeclaration::WordBreak(value)) + } + + /// Sets `overflow-wrap`. + #[inline] + pub fn overflow_wrap(self, value: Specified) -> Self { + self.push(ParagraphDeclaration::OverflowWrap(value)) + } + + /// Sets `text-wrap-mode`. + #[inline] + pub fn text_wrap_mode(self, value: Specified) -> Self { + self.push(ParagraphDeclaration::TextWrapMode(value)) + } +} diff --git a/styled_text/src/style/settings.rs b/styled_text/src/style/settings.rs new file mode 100644 index 00000000..abd08501 --- /dev/null +++ b/styled_text/src/style/settings.rs @@ -0,0 +1,46 @@ +// Copyright 2025 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use alloc::sync::Arc; + +pub use text_primitives::{FontFeature, FontVariation, Tag}; + +/// Font variation settings (OpenType axis values). +/// +/// This is a typed wrapper over a list of [`FontVariation`] values. +#[derive(Clone, PartialEq, Debug, Default)] +pub struct FontVariations(Arc<[FontVariation]>); + +impl FontVariations { + /// Creates settings from a parsed list. + #[inline] + pub fn list(list: impl Into>) -> Self { + Self(list.into()) + } + + /// Returns the backing shared slice. + #[inline] + pub const fn as_arc_slice(&self) -> &Arc<[FontVariation]> { + &self.0 + } +} + +/// Font feature settings (OpenType feature values). +/// +/// This is a typed wrapper over a list of [`FontFeature`] values. +#[derive(Clone, PartialEq, Debug, Default)] +pub struct FontFeatures(Arc<[FontFeature]>); + +impl FontFeatures { + /// Creates settings from a parsed list. + #[inline] + pub fn list(list: impl Into>) -> Self { + Self(list.into()) + } + + /// Returns the backing shared slice. + #[inline] + pub const fn as_arc_slice(&self) -> &Arc<[FontFeature]> { + &self.0 + } +} diff --git a/styled_text/src/style/specified.rs b/styled_text/src/style/specified.rs new file mode 100644 index 00000000..f774374d --- /dev/null +++ b/styled_text/src/style/specified.rs @@ -0,0 +1,21 @@ +// Copyright 2025 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +/// A specified value in a style, using CSS-like reset semantics. +/// +/// See the module docs for how `inherit` and `initial` interact with resolution: [`super`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Specified { + /// Use the property's value from the parent style. + Inherit, + /// Reset the property to the initial value. + Initial, + /// Provide an explicit value. + Value(T), +} + +impl From for Specified { + fn from(value: T) -> Self { + Self::Value(value) + } +} diff --git a/styled_text/src/style/values.rs b/styled_text/src/style/values.rs new file mode 100644 index 00000000..f827931c --- /dev/null +++ b/styled_text/src/style/values.rs @@ -0,0 +1,58 @@ +// Copyright 2025 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +/// A specified font size. +/// +/// Relative sizes like [`FontSize::Em`] are resolved against the **parent** computed font size. +/// See the module docs for details on the resolution model and how CSS keyword sizes like +/// `larger`/`smaller` can be represented: [`super`]. +/// +/// See: +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum FontSize { + /// An absolute size in CSS pixels. + Px(f32), + /// A size relative to the parent font size. + Em(f32), + /// A size relative to the root font size. + Rem(f32), +} + +/// A specified "spacing" value, such as `letter-spacing` or `word-spacing`. +/// +/// Relative values like [`Spacing::Em`] are resolved against the computed font size for the style. +/// See the module docs for details: [`super`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Spacing { + /// An absolute value in CSS pixels. + Px(f32), + /// A value relative to the current font size. + Em(f32), + /// A value relative to the root font size. + Rem(f32), +} + +/// A specified font style. +pub use text_primitives::FontStyle; + +/// A specified line height. +/// +/// The relationship between line-height, font size, and font metrics is engine-dependent; this +/// is typically resolved by an engine layer (for example [`resolve`](crate::resolve)) into a +/// computed line height that can be lowered to engine-specific representations. +/// +/// See: +#[derive(Clone, Copy, Debug, PartialEq, Default)] +pub enum LineHeight { + /// `normal`. + #[default] + Normal, + /// A unitless multiplier of the font size (CSS `line-height: `). + Factor(f32), + /// An absolute value in CSS pixels. + Px(f32), + /// A value relative to the font size. + Em(f32), + /// A value relative to the root font size. + Rem(f32), +} diff --git a/styled_text/src/tests.rs b/styled_text/src/tests.rs new file mode 100644 index 00000000..47b51879 --- /dev/null +++ b/styled_text/src/tests.rs @@ -0,0 +1,371 @@ +// Copyright 2025 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::block::{Block, BlockKind}; +use crate::document::StyledDocument; +use crate::text::StyledText; +use crate::{ + ComputedInlineStyle, ComputedParagraphStyle, FontSize, InlineResolveContext, InlineStyle, + ResolveStyleExt, Specified, +}; +use alloc::vec::Vec; +use core::ops::Range; +use text_primitives::BaseDirection; + +/// Reference implementation of inline run resolution. +/// +/// This intentionally uses the simplest (and slowest) algorithm: for each boundary segment, scan +/// all spans that overlap it, merge their declarations in authoring order, then resolve once. +/// +/// The production implementation in `styled_text` uses a sweep-line that maintains an active span +/// set and avoids scanning all spans per segment. This helper exists to assert that the fast path +/// preserves identical semantics. +fn reference_resolved_inline_runs( + text: &StyledText<&str, InlineStyle>, +) -> Vec<(Range, ComputedInlineStyle)> { + let len = text.attributed.len(); + let mut boundaries = Vec::new(); + boundaries.push(0); + boundaries.push(len); + for (range, _) in text.attributed.attributes_iter() { + boundaries.push(range.start); + boundaries.push(range.end); + } + boundaries.sort_unstable(); + boundaries.dedup(); + + let ctx = InlineResolveContext::new(&text.base_inline, &text.initial_inline, &text.root_inline); + + let mut out = Vec::new(); + for pair in boundaries.windows(2) { + let start = pair[0]; + let end = pair[1]; + if start == end { + continue; + } + + let mut merged = InlineStyle::new(); + for (range, attr) in text.attributed.attributes_iter() { + if range.start < end && range.end > start { + for declaration in attr.declarations() { + merged.push_declaration(declaration.clone()); + } + } + } + let computed = merged.resolve(ctx); + out.push((start..end, computed)); + } + out +} + +/// Coalesces adjacent runs with equal computed styles. +/// +/// This mirrors the behavior of `resolved_inline_runs_coalesced()` but operates on the `(Range, +/// ComputedInlineStyle)` tuples produced by `reference_resolved_inline_runs`. +fn coalesce_runs( + runs: &[(Range, ComputedInlineStyle)], +) -> Vec<(Range, ComputedInlineStyle)> { + let mut out: Vec<(Range, ComputedInlineStyle)> = Vec::new(); + for (range, style) in runs { + match out.last_mut() { + Some((last_range, last_style)) + if last_range.end == range.start && last_style == style => + { + last_range.end = range.end; + } + _ => out.push((range.clone(), style.clone())), + } + } + out +} + +#[test] +fn produces_split_runs() { + let base_inline = ComputedInlineStyle::default(); + let base_paragraph = ComputedParagraphStyle::default(); + let mut text = StyledText::new("Hello world!", base_inline, base_paragraph); + text.apply_span( + text.range(0..5).unwrap(), + InlineStyle::new().font_size(Specified::Value(FontSize::Em(2.0))), + ); + let runs: Vec<_> = text.resolved_inline_runs().collect(); + assert_eq!(runs.len(), 2); + assert_eq!(runs[0].range, 0..5); + assert_eq!(runs[1].range, 5..12); +} + +#[test] +fn set_text_clears_spans_and_paragraph_declarations() { + let base_inline = ComputedInlineStyle::default(); + let base_paragraph = ComputedParagraphStyle::default(); + let mut text = StyledText::new("Hello", base_inline, base_paragraph); + + text.apply_span( + text.range(0..5).unwrap(), + InlineStyle::new().font_size(Specified::Value(FontSize::Px(20.0))), + ); + text.set_paragraph_style( + crate::ParagraphStyle::new().base_direction(Specified::Value(BaseDirection::Ltr)), + ); + assert_eq!(text.attributed.attributes_len(), 1); + assert_eq!(text.paragraph_style().declarations().len(), 1); + + text.set_text("World"); + assert_eq!(text.attributed.attributes_len(), 0); + assert_eq!(text.paragraph_style().declarations().len(), 0); + assert_eq!(text.attributed.text(), &"World"); +} + +#[test] +fn overlap_is_ordered() { + let base_inline = ComputedInlineStyle::default(); + let base_paragraph = ComputedParagraphStyle::default(); + let mut text = StyledText::new("abc", base_inline.clone(), base_paragraph); + text.apply_span( + text.range(0..3).unwrap(), + InlineStyle::new().font_size(Specified::Value(FontSize::Em(2.0))), + ); + text.apply_span( + text.range(1..2).unwrap(), + InlineStyle::new().font_size(Specified::Value(FontSize::Px(10.0))), + ); + + let runs: Vec<_> = text.resolved_inline_runs().collect(); + assert_eq!(runs.len(), 3); + assert_eq!( + runs[0].style.font_size_px(), + base_inline.font_size_px() * 2.0 + ); + assert_eq!(runs[1].style.font_size_px(), 10.0); + assert_eq!( + runs[2].style.font_size_px(), + base_inline.font_size_px() * 2.0 + ); +} + +#[test] +fn dependent_properties_resolve_against_final_computed_style() { + let base_inline = ComputedInlineStyle::default(); + let base_paragraph = ComputedParagraphStyle::default(); + let mut text = StyledText::new("abc", base_inline, base_paragraph); + + // Apply letter-spacing first, then change font-size. The `em` spacing should resolve against + // the final computed font size for the run. + text.apply_span( + text.range(0..3).unwrap(), + InlineStyle::new().letter_spacing(Specified::Value(crate::Spacing::Em(0.5))), + ); + text.apply_span( + text.range(0..3).unwrap(), + InlineStyle::new().font_size(Specified::Value(FontSize::Px(20.0))), + ); + + let runs: Vec<_> = text.resolved_inline_runs().collect(); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].style.font_size_px(), 20.0); + assert_eq!(runs[0].style.letter_spacing_px(), 10.0); +} + +#[test] +fn coalesces_adjacent_equal_runs() { + let base_inline = ComputedInlineStyle::default(); + let base_paragraph = ComputedParagraphStyle::default(); + let mut text = StyledText::new("ab", base_inline, base_paragraph); + + let style = InlineStyle::new().font_size(Specified::Value(FontSize::Px(20.0))); + text.apply_span(text.range(0..1).unwrap(), style.clone()); + text.apply_span(text.range(1..2).unwrap(), style); + + let runs: Vec<_> = text.resolved_inline_runs_coalesced().collect(); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].range, 0..2); + assert_eq!(runs[0].style.font_size_px(), 20.0); +} + +#[test] +fn inherit_can_reset_a_property_within_an_overlap() { + let base_inline = ComputedInlineStyle::default(); + let base_paragraph = ComputedParagraphStyle::default(); + let mut text = StyledText::new("abc", base_inline.clone(), base_paragraph); + + text.apply_span( + text.range(0..3).unwrap(), + InlineStyle::new().font_size(Specified::Value(FontSize::Px(20.0))), + ); + text.apply_span( + text.range(1..2).unwrap(), + InlineStyle::new().font_size(Specified::Inherit), + ); + + let runs: Vec<_> = text.resolved_inline_runs().collect(); + assert_eq!(runs.len(), 3); + assert_eq!(runs[0].style.font_size_px(), 20.0); + assert_eq!(runs[1].style.font_size_px(), base_inline.font_size_px()); + assert_eq!(runs[2].style.font_size_px(), 20.0); +} + +#[test] +fn initial_uses_block_initial_style_not_base_style() { + let base_inline = ComputedInlineStyle::default().with_font_size_px(20.0); + let initial_inline = ComputedInlineStyle::default().with_font_size_px(10.0); + let base_paragraph = ComputedParagraphStyle::default(); + let initial_paragraph = ComputedParagraphStyle::default(); + + let mut text = StyledText::new_with_initial( + "abc", + base_inline.clone(), + initial_inline.clone(), + base_paragraph, + initial_paragraph, + ); + + text.apply_span( + text.range(0..3).unwrap(), + InlineStyle::new().font_size(Specified::Initial), + ); + let runs: Vec<_> = text.resolved_inline_runs().collect(); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].style.font_size_px(), 10.0); +} + +#[test] +fn rem_uses_root_style_when_set() { + let base_inline = ComputedInlineStyle::default(); + let base_paragraph = ComputedParagraphStyle::default(); + let mut text = StyledText::new("abc", base_inline, base_paragraph); + + let root_inline = ComputedInlineStyle::default().with_font_size_px(10.0); + let root_paragraph = ComputedParagraphStyle::default(); + text.set_root_styles(root_inline, root_paragraph); + + text.apply_span( + text.range(0..3).unwrap(), + InlineStyle::new().font_size(Specified::Value(FontSize::Rem(2.0))), + ); + let runs: Vec<_> = text.resolved_inline_runs().collect(); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].style.font_size_px(), 20.0); +} + +#[test] +fn document_root_styles_apply_to_new_blocks() { + let mut doc = StyledDocument::new_with_root( + ComputedInlineStyle::default().with_font_size_px(10.0), + ComputedParagraphStyle::default(), + ); + + let mut text = StyledText::new( + "abc", + ComputedInlineStyle::default(), + ComputedParagraphStyle::default(), + ); + text.apply_span( + text.range(0..3).unwrap(), + InlineStyle::new().font_size(Specified::Value(FontSize::Rem(2.0))), + ); + + doc.push(Block { + kind: BlockKind::Paragraph, + nesting: 0, + text, + }); + + let runs: Vec<_> = doc + .iter() + .next() + .unwrap() + .text + .resolved_inline_runs() + .collect(); + assert_eq!(runs[0].style.font_size_px(), 20.0); +} + +#[test] +fn sweep_line_matches_reference_for_many_overlaps() { + use crate::{FontWeight, Spacing}; + + struct Lcg(u64); + impl Lcg { + fn new(seed: u64) -> Self { + Self(seed) + } + fn next_u32(&mut self) -> u32 { + self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1); + (self.0 >> 32) as u32 + } + fn next_usize(&mut self, max: usize) -> usize { + if max == 0 { + 0 + } else { + (self.next_u32() as usize) % max + } + } + fn next_f32(&mut self, min: f32, max: f32) -> f32 { + let t = (self.next_u32() as f32) / (u32::MAX as f32); + min + (max - min) * t + } + fn next_bool(&mut self) -> bool { + (self.next_u32() & 1) == 1 + } + } + + let base_inline = ComputedInlineStyle::default().with_font_size_px(14.0); + let base_paragraph = ComputedParagraphStyle::default(); + let content = "0123456789abcdef0123456789abcdef"; + + let mut rng = Lcg::new(0x1234_5678_9abc_def0); + for _case in 0..200 { + let mut text = StyledText::new(content, base_inline.clone(), base_paragraph.clone()); + + let span_count = rng.next_usize(25); + for _ in 0..span_count { + let mut start = rng.next_usize(content.len() + 1); + let mut end = rng.next_usize(content.len() + 1); + if start > end { + core::mem::swap(&mut start, &mut end); + } + if start == end { + continue; + } + + let mut style = InlineStyle::new(); + let decl_count = 1 + rng.next_usize(4); + for _ in 0..decl_count { + match rng.next_usize(5) { + 0 => { + // Intentionally allow multiple declarations of the same property within a + // single span; last wins. + let px = rng.next_f32(8.0, 40.0); + style = style.font_size(Specified::Value(FontSize::Px(px))); + } + 1 => { + let px = rng.next_f32(-2.0, 8.0); + style = style.letter_spacing(Specified::Value(Spacing::Px(px))); + } + 2 => style = style.underline(Specified::Value(rng.next_bool())), + 3 => { + let w = rng.next_f32(1.0, 1000.0); + style = style.font_weight(Specified::Value(FontWeight::new(w))); + } + _ => style = style.font_size(Specified::Initial), + } + } + + text.apply_span(text.range(start..end).unwrap(), style); + } + + let expected = reference_resolved_inline_runs(&text); + let actual: Vec<_> = text + .resolved_inline_runs() + .map(|run| (run.range, run.style)) + .collect(); + assert_eq!(actual, expected); + + let expected_coalesced = coalesce_runs(&expected); + let actual_coalesced: Vec<_> = text + .resolved_inline_runs_coalesced() + .map(|run| (run.range, run.style)) + .collect(); + assert_eq!(actual_coalesced, expected_coalesced); + } +} diff --git a/styled_text/src/text.rs b/styled_text/src/text.rs new file mode 100644 index 00000000..3f44c384 --- /dev/null +++ b/styled_text/src/text.rs @@ -0,0 +1,192 @@ +// Copyright 2025 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use core::fmt::Debug; +use core::ops::Range; + +use crate::{ + ComputedInlineStyle, ComputedParagraphStyle, ParagraphResolveContext, ParagraphStyle, + ResolveStyleExt, +}; +use attributed_text::{AttributedText, Error, TextRange, TextStorage}; + +use crate::runs::{CoalescedInlineRuns, ResolvedInlineRuns}; +use crate::traits::HasInlineStyle; + +/// A single layout block worth of styled text. +/// +/// This is designed to map 1:1 to a single Parley `Layout` ("one paragraph per layout"). +#[derive(Debug)] +pub struct StyledText { + pub(crate) attributed: AttributedText, + pub(crate) paragraph_style: ParagraphStyle, + pub(crate) base_inline: ComputedInlineStyle, + pub(crate) initial_inline: ComputedInlineStyle, + pub(crate) root_inline: ComputedInlineStyle, + pub(crate) base_paragraph: ComputedParagraphStyle, + pub(crate) initial_paragraph: ComputedParagraphStyle, + pub(crate) root_paragraph: ComputedParagraphStyle, +} + +impl StyledText { + /// Creates a new `StyledText` with base styles. + /// + /// The base styles also serve as the "initial" values for [`Specified::Initial`](crate::Specified::Initial). + #[inline] + pub fn new( + text: T, + base_inline: ComputedInlineStyle, + base_paragraph: ComputedParagraphStyle, + ) -> Self { + Self::new_with_initial( + text, + base_inline.clone(), + base_inline, + base_paragraph.clone(), + base_paragraph, + ) + } + + /// Creates a new `StyledText` with separate base and initial styles. + #[inline] + pub fn new_with_initial( + text: T, + base_inline: ComputedInlineStyle, + initial_inline: ComputedInlineStyle, + base_paragraph: ComputedParagraphStyle, + initial_paragraph: ComputedParagraphStyle, + ) -> Self { + Self { + attributed: AttributedText::new(text), + paragraph_style: ParagraphStyle::new(), + base_inline, + root_inline: initial_inline.clone(), + initial_inline, + base_paragraph, + root_paragraph: initial_paragraph.clone(), + initial_paragraph, + } + } + + /// Returns the underlying text as `&str` when the storage is contiguous. + #[inline] + pub fn as_str(&self) -> &str + where + T: AsRef, + { + self.attributed.as_str() + } + + /// Returns the base computed inline style used for run resolution. + #[inline] + pub fn base_inline_style(&self) -> &ComputedInlineStyle { + &self.base_inline + } + + /// Returns the base computed paragraph style used for paragraph resolution. + #[inline] + pub fn base_paragraph_style(&self) -> &ComputedParagraphStyle { + &self.base_paragraph + } + + /// Sets the paragraph style declarations for this block. + pub fn set_paragraph_style(&mut self, style: ParagraphStyle) { + self.paragraph_style = style; + } + + /// Returns the paragraph style declarations for this block. + #[inline] + pub fn paragraph_style(&self) -> &ParagraphStyle { + &self.paragraph_style + } + + /// Returns the computed paragraph style for this block. + pub fn computed_paragraph_style(&self) -> ComputedParagraphStyle { + self.paragraph_style.resolve(ParagraphResolveContext::new( + &self.base_paragraph, + &self.initial_paragraph, + &self.root_paragraph, + )) + } + + /// Sets the root computed styles used for root-relative units such as `rem`. + pub fn set_root_styles( + &mut self, + root_inline: ComputedInlineStyle, + root_paragraph: ComputedParagraphStyle, + ) { + self.root_inline = root_inline; + self.root_paragraph = root_paragraph; + } + + /// Returns the root inline style used for root-relative units such as `rem`. + #[inline] + pub fn root_inline_style(&self) -> &ComputedInlineStyle { + &self.root_inline + } + + /// Returns the root paragraph style used for root-relative properties. + #[inline] + pub fn root_paragraph_style(&self) -> &ComputedParagraphStyle { + &self.root_paragraph + } + + /// Applies a span attribute to a validated [`TextRange`]. + #[inline] + pub fn apply_span(&mut self, range: TextRange, attribute: A) { + self.attributed.apply_attribute(range, attribute); + } + + /// Clears all applied span attributes, retaining allocated storage. + #[inline] + pub fn clear_spans(&mut self) { + self.attributed.clear_attributes(); + } + + /// Clears the paragraph style declarations, retaining allocated storage. + #[inline] + pub fn clear_paragraph_style(&mut self) { + self.paragraph_style.clear(); + } + + /// Replaces the underlying text and clears span attributes and paragraph declarations. + /// + /// This retains allocated storage for spans and declarations so the same `StyledText` value + /// can be reused across rebuilds. + #[inline] + pub fn set_text(&mut self, text: T) { + self.attributed.set_text(text); + self.paragraph_style.clear(); + } + + /// Applies a span attribute to the specified byte `range`. + /// + /// This validates the range (bounds + UTF-8 codepoint boundaries) before applying it. + #[inline] + pub fn apply_span_bytes(&mut self, range: Range, attribute: A) -> Result<(), Error> { + self.attributed.apply_attribute_bytes(range, attribute) + } + + /// Validates a byte `range` against this text and returns a [`TextRange`]. + #[inline] + pub fn range(&self, range: Range) -> Result { + TextRange::new(self.attributed.text(), range) + } +} + +impl StyledText { + /// Returns an iterator over resolved inline style runs. + /// + /// Overlapping spans are applied in the order they were added (last writer wins). + #[inline] + pub fn resolved_inline_runs(&self) -> ResolvedInlineRuns<'_, T, A> { + ResolvedInlineRuns::new(self) + } + + /// Returns an iterator over resolved inline style runs, coalescing adjacent runs with the same + /// computed style. + #[inline] + pub fn resolved_inline_runs_coalesced(&self) -> CoalescedInlineRuns<'_, T, A> { + CoalescedInlineRuns::new(self) + } +} diff --git a/styled_text/src/traits.rs b/styled_text/src/traits.rs new file mode 100644 index 00000000..3c585889 --- /dev/null +++ b/styled_text/src/traits.rs @@ -0,0 +1,19 @@ +// Copyright 2025 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::InlineStyle; + +/// Extracts the inline style declarations from a span attribute. +/// +/// This enables wrapper attribute types to carry additional semantic information (links, custom +/// annotations, etc.) while still allowing `styled_text` to resolve inline styles. +pub trait HasInlineStyle { + /// Returns the inline style attached to this span. + fn inline_style(&self) -> &InlineStyle; +} + +impl HasInlineStyle for InlineStyle { + fn inline_style(&self) -> &InlineStyle { + self + } +} diff --git a/styled_text_parley/Cargo.toml b/styled_text_parley/Cargo.toml new file mode 100644 index 00000000..27be2b1e --- /dev/null +++ b/styled_text_parley/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "styled_text_parley" +version = "0.1.0" +description = "Parley backend for styled_text" +keywords = ["text", "style", "parley"] +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 = ["parley/std"] +libm = ["parley/libm"] + +[dependencies] +attributed_text.workspace = true +parley.workspace = true +styled_text.workspace = true + +[lints] +workspace = true diff --git a/styled_text_parley/LICENSE-APACHE b/styled_text_parley/LICENSE-APACHE new file mode 100644 index 00000000..16fe87b0 --- /dev/null +++ b/styled_text_parley/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/styled_text_parley/LICENSE-MIT b/styled_text_parley/LICENSE-MIT new file mode 100644 index 00000000..657288a5 --- /dev/null +++ b/styled_text_parley/LICENSE-MIT @@ -0,0 +1,25 @@ +Copyright 2020 the Parley Authors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/styled_text_parley/README.md b/styled_text_parley/README.md new file mode 100644 index 00000000..34ddbdd4 --- /dev/null +++ b/styled_text_parley/README.md @@ -0,0 +1,66 @@ +
+ +# Styled Text Parley + +Parley backend for `styled_text`. + +[![Linebender Zulip, #parley channel](https://img.shields.io/badge/Linebender-%23parley-blue?logo=Zulip)](https://xi.zulipchat.com/#narrow/channel/205635-parley) +[![dependency status](https://deps.rs/repo/github/linebender/parley/status.svg)](https://deps.rs/repo/github/linebender/parley) +[![Apache 2.0 or MIT license.](https://img.shields.io/badge/license-Apache--2.0_OR_MIT-blue.svg)](#license) +[![Build status](https://github.com/linebender/parley/workflows/CI/badge.svg)](https://github.com/linebender/parley/actions) +[![Crates.io](https://img.shields.io/crates/v/styled_text_parley.svg)](https://crates.io/crates/styled_text_parley) +[![Docs](https://docs.rs/styled_text_parley/badge.svg)](https://docs.rs/styled_text_parley) + +
+ + + + + + +Parley backend for [`styled_text`]. + +This crate lowers `styled_text`’s resolved computed style runs into Parley builder calls, +producing a [`parley::Layout`]. + +## Scope + +This crate focuses on mapping `styled_text` computed styles into Parley +[`parley::StyleProperty`] values. + +It intentionally does not handle: +- paint/brush resolution (callers provide a default brush and may extend this crate later) +- inline bidi controls / forced base direction (not currently modeled by Parley style properties) +- inline boxes / attachments (use `parley::InlineBox` directly when you add an attachment layer) + +## Example + +```rust +use parley::{FontContext, Layout, LayoutContext}; +use styled_text::StyledText; +use styled_text_parley::build_layout_from_styled_text; +use styled_text::{ComputedInlineStyle, ComputedParagraphStyle, FontSize, InlineStyle, Specified}; + +let mut font_cx = FontContext::new(); +let mut layout_cx = LayoutContext::new(); +let base_inline = ComputedInlineStyle::default(); +let base_paragraph = ComputedParagraphStyle::default(); +let mut text = StyledText::new("Hello world!", base_inline, base_paragraph); +text.apply_span( + text.range(6..12).unwrap(), + InlineStyle::new().font_size(Specified::Value(FontSize::Em(1.5))), +); + +let layout: Layout<()> = + build_layout_from_styled_text(&mut layout_cx, &mut font_cx, &text, 1.0, true, ()); +``` + + + +## Minimum supported Rust Version (MSRV) + +This version of Styled Text Parley has been verified to compile with **Rust 1.83** and later. diff --git a/styled_text_parley/src/convert.rs b/styled_text_parley/src/convert.rs new file mode 100644 index 00000000..4af4f6a8 --- /dev/null +++ b/styled_text_parley/src/convert.rs @@ -0,0 +1,14 @@ +// Copyright 2025 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use parley::LineHeight as ParleyLineHeight; +use styled_text::ComputedLineHeight; + +#[inline] +pub(crate) fn to_parley_line_height(line_height: ComputedLineHeight) -> ParleyLineHeight { + match line_height { + ComputedLineHeight::MetricsRelative(x) => ParleyLineHeight::MetricsRelative(x), + ComputedLineHeight::FontSizeRelative(x) => ParleyLineHeight::FontSizeRelative(x), + ComputedLineHeight::Px(px) => ParleyLineHeight::Absolute(px), + } +} diff --git a/styled_text_parley/src/lib.rs b/styled_text_parley/src/lib.rs new file mode 100644 index 00000000..1e8a8dcd --- /dev/null +++ b/styled_text_parley/src/lib.rs @@ -0,0 +1,195 @@ +// Copyright 2025 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Parley backend for [`styled_text`]. +//! +//! This crate lowers `styled_text`’s resolved computed style runs into Parley builder calls, +//! producing a [`parley::Layout`]. +//! +//! ## Scope +//! +//! This crate focuses on mapping `styled_text` computed styles into Parley +//! [`parley::StyleProperty`] values. +//! +//! It intentionally does not handle: +//! - paint/brush resolution (callers provide a default brush and may extend this crate later) +//! - inline bidi controls / forced base direction (not currently modeled by Parley style properties) +//! - inline boxes / attachments (use `parley::InlineBox` directly when you add an attachment layer) +//! +//! ## Example +//! +//! ```no_run +//! use parley::{FontContext, Layout, LayoutContext}; +//! use styled_text::StyledText; +//! use styled_text_parley::build_layout_from_styled_text; +//! use styled_text::{ComputedInlineStyle, ComputedParagraphStyle, FontSize, InlineStyle, Specified}; +//! +//! let mut font_cx = FontContext::new(); +//! let mut layout_cx = LayoutContext::new(); +//! let base_inline = ComputedInlineStyle::default(); +//! let base_paragraph = ComputedParagraphStyle::default(); +//! let mut text = StyledText::new("Hello world!", base_inline, base_paragraph); +//! text.apply_span( +//! text.range(6..12).unwrap(), +//! InlineStyle::new().font_size(Specified::Value(FontSize::Em(1.5))), +//! ); +//! +//! let layout: Layout<()> = +//! build_layout_from_styled_text(&mut layout_cx, &mut font_cx, &text, 1.0, true, ()); +//! ``` +// LINEBENDER LINT SET - lib.rs - v3 +// See https://linebender.org/wiki/canonical-lints/ +// These lints shouldn't apply to examples or tests. +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +// These lints shouldn't apply to examples. +#![warn(clippy::print_stdout, clippy::print_stderr)] +// Targeting e.g. 32-bit means structs containing usize can give false positives for 64-bit. +#![cfg_attr(target_pointer_width = "64", warn(clippy::trivially_copy_pass_by_ref))] +// END LINEBENDER LINT SET +#![cfg_attr(docsrs, feature(doc_cfg))] +#![no_std] + +extern crate alloc; + +use core::fmt::Debug; + +use parley::style::Brush; +use parley::{ + FontContext, FontFeatures, FontVariations, Layout, LayoutContext, RangedBuilder, StyleProperty, +}; +use styled_text::{ComputedInlineStyle, StyledText}; + +mod convert; + +#[cfg(test)] +mod tests; + +use crate::convert::to_parley_line_height; + +/// Builds a Parley [`Layout`] from a [`StyledText`]. +/// +/// This uses Parley’s ranged builder and applies computed inline runs as explicit `StyleProperty` +/// spans. The returned `Layout` has been shaped, but line breaking and alignment are left to the +/// caller. +pub fn build_layout_from_styled_text( + layout_cx: &mut LayoutContext, + font_cx: &mut FontContext, + styled: &StyledText, + scale: f32, + quantize: bool, + default_brush: B, +) -> Layout +where + T: Debug + attributed_text::TextStorage + AsRef, + A: Debug + styled_text::HasInlineStyle, + B: Brush + Clone, +{ + let text = styled.as_str(); + let mut builder = layout_cx.ranged_builder(font_cx, text, scale, quantize); + + let default_inline = styled.base_inline_style(); + push_inline_defaults(&mut builder, default_inline, default_brush.clone()); + + // Paragraph-level properties currently supported by Parley. + let paragraph = styled.computed_paragraph_style(); + builder.push_default(StyleProperty::WordBreak(paragraph.word_break())); + builder.push_default(StyleProperty::OverflowWrap(paragraph.overflow_wrap())); + builder.push_default(StyleProperty::TextWrapMode(paragraph.text_wrap_mode())); + + for run in styled.resolved_inline_runs_coalesced() { + push_run_diffs(&mut builder, default_inline, &run.style, run.range); + } + + builder.build(text) +} + +fn push_inline_defaults( + builder: &mut RangedBuilder<'_, B>, + style: &ComputedInlineStyle, + brush: B, +) { + builder.push_default(StyleProperty::Brush(brush)); + builder.push_default(StyleProperty::FontFamily(style.font_family().clone())); + builder.push_default(StyleProperty::FontSize(style.font_size_px())); + builder.push_default(StyleProperty::FontWidth(style.font_width())); + builder.push_default(StyleProperty::FontStyle(style.font_style())); + builder.push_default(StyleProperty::FontWeight(style.font_weight())); + builder.push_default(FontVariations::from(style.font_variations())); + builder.push_default(FontFeatures::from(style.font_features())); + builder.push_default(StyleProperty::Locale(style.locale().copied())); + builder.push_default(StyleProperty::Underline(style.underline())); + builder.push_default(StyleProperty::Strikethrough(style.strikethrough())); + builder.push_default(StyleProperty::LineHeight(to_parley_line_height( + style.line_height(), + ))); + builder.push_default(StyleProperty::WordSpacing(style.word_spacing_px())); + builder.push_default(StyleProperty::LetterSpacing(style.letter_spacing_px())); +} + +fn push_run_diffs( + builder: &mut RangedBuilder<'_, B>, + default: &ComputedInlineStyle, + run: &ComputedInlineStyle, + range: core::ops::Range, +) { + macro_rules! push_if { + ($cond:expr, $prop:expr) => { + if $cond { + builder.push($prop, range.clone()); + } + }; + } + + push_if!( + run.font_family() != default.font_family(), + StyleProperty::FontFamily(run.font_family().clone()) + ); + push_if!( + run.font_size_px() != default.font_size_px(), + StyleProperty::FontSize(run.font_size_px()) + ); + push_if!( + run.font_width() != default.font_width(), + StyleProperty::FontWidth(run.font_width()) + ); + push_if!( + run.font_style() != default.font_style(), + StyleProperty::FontStyle(run.font_style()) + ); + push_if!( + run.font_weight() != default.font_weight(), + StyleProperty::FontWeight(run.font_weight()) + ); + push_if!( + run.font_variations() != default.font_variations(), + FontVariations::from(run.font_variations()) + ); + push_if!( + run.font_features() != default.font_features(), + FontFeatures::from(run.font_features()) + ); + push_if!( + run.locale() != default.locale(), + StyleProperty::Locale(run.locale().copied()) + ); + push_if!( + run.underline() != default.underline(), + StyleProperty::Underline(run.underline()) + ); + push_if!( + run.strikethrough() != default.strikethrough(), + StyleProperty::Strikethrough(run.strikethrough()) + ); + push_if!( + run.line_height() != default.line_height(), + StyleProperty::LineHeight(to_parley_line_height(run.line_height())) + ); + push_if!( + run.word_spacing_px() != default.word_spacing_px(), + StyleProperty::WordSpacing(run.word_spacing_px()) + ); + push_if!( + run.letter_spacing_px() != default.letter_spacing_px(), + StyleProperty::LetterSpacing(run.letter_spacing_px()) + ); +} diff --git a/styled_text_parley/src/tests.rs b/styled_text_parley/src/tests.rs new file mode 100644 index 00000000..f3402808 --- /dev/null +++ b/styled_text_parley/src/tests.rs @@ -0,0 +1,45 @@ +// Copyright 2025 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use parley::style::LineHeight; +use styled_text::{FontFamily, FontFamilyName, GenericFamily, Tag}; + +use crate::convert::to_parley_line_height; + +#[test] +fn converts_font_family_to_parley_font_family() { + static NAMES: [FontFamilyName; 2] = [ + FontFamilyName::named("Inter"), + FontFamilyName::Generic(GenericFamily::SansSerif), + ]; + let family = FontFamily::from(&NAMES[..]); + let parley = family; + assert!(matches!( + parley, + parley::FontFamily::List(list) + if matches!(&list[0], parley::FontFamilyName::Named(name) if name.as_ref() == "Inter") + )); +} + +#[test] +fn converts_tags_by_bytes() { + let tag = Tag::new(b"wght"); + let parley = tag; + assert_eq!(parley.to_bytes(), *b"wght"); +} + +#[test] +fn converts_line_height_variants() { + assert_eq!( + to_parley_line_height(styled_text::ComputedLineHeight::MetricsRelative(1.0)), + LineHeight::MetricsRelative(1.0) + ); + assert_eq!( + to_parley_line_height(styled_text::ComputedLineHeight::FontSizeRelative(1.5)), + LineHeight::FontSizeRelative(1.5) + ); + assert_eq!( + to_parley_line_height(styled_text::ComputedLineHeight::Px(12.0)), + LineHeight::Absolute(12.0) + ); +}