From 19b7d3ac78f4a2f328369a824299a5efb93fb0ab Mon Sep 17 00:00:00 2001
From: Mike Solar
Date: Fri, 11 Sep 2026 08:47:33 +0800
Subject: [PATCH] render: move the text engine into oak-render and install it
in the worker
The render worker process never installed a text backend, so text clips
rendered as empty frames in playback and export. The cosmic-text engine
now lives in oak-render (the crate both the app and the worker link),
and the worker installs it during runtime initialization.
---
Cargo.lock | 2 +-
crates/oak-app/Cargo.toml | 5 -
crates/oak-app/src/oakui/textengine.rs | 849 +-----------------------
crates/oak-render/Cargo.toml | 5 +
crates/oak-render/src/lib.rs | 1 +
crates/oak-render/src/textengine.rs | 861 +++++++++++++++++++++++++
crates/oak-worker/src/worker.rs | 6 +
7 files changed, 879 insertions(+), 850 deletions(-)
create mode 100644 crates/oak-render/src/textengine.rs
diff --git a/Cargo.lock b/Cargo.lock
index 8958e5286..1015c34d9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4723,7 +4723,6 @@ dependencies = [
name = "oak-app"
version = "0.5.0"
dependencies = [
- "cosmic-text",
"embed-resource",
"gpui",
"gpui_elements",
@@ -4836,6 +4835,7 @@ dependencies = [
name = "oak-render"
version = "0.5.0"
dependencies = [
+ "cosmic-text",
"libc",
"naga 25.0.1",
"oak-codec",
diff --git a/crates/oak-app/Cargo.toml b/crates/oak-app/Cargo.toml
index b17558c6a..27f66722c 100644
--- a/crates/oak-app/Cargo.toml
+++ b/crates/oak-app/Cargo.toml
@@ -65,11 +65,6 @@ serde_yaml = "0.9"
# stderr backend (see oakapp::logging) so validation errors and warnings
# are actually visible (RUST_LOG selects the verbosity).
log = "0.4"
-# Text shaping/rasterization for the text generator nodes: the app is the
-# facade layer that installs oak-node's text backend hooks (see
-# oakui/textengine.rs). Pinned to the version the gpui_wgpu backend already
-# links so the lockfile keeps a single cosmic-text.
-cosmic-text = "=0.19.0"
# M14 R3: the app is a PURE module-crate consumer — every engine call is a
# direct Rust call into the oak* rlibs (oak-node for the project graph,
diff --git a/crates/oak-app/src/oakui/textengine.rs b/crates/oak-app/src/oakui/textengine.rs
index 8897fe97b..2bf906663 100644
--- a/crates/oak-app/src/oakui/textengine.rs
+++ b/crates/oak-app/src/oakui/textengine.rs
@@ -14,848 +14,9 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
-//! cosmic-text backend for the text generator nodes: the facade-layer half
-//! of [`oak_node::nodes::textbackend`].
-//!
-//! `oak-node` deliberately links no font or shaping crate: the text
-//! generator nodes describe their job with the
-//! [`TextLayoutRequest`](oak_node::nodes::textbackend::TextLayoutRequest) /
-//! [`TextRenderTarget`](oak_node::nodes::textbackend::TextRenderTarget) PODs
-//! and call the two function-pointer hooks that [`install`] fills in. This
-//! module is that backend: layout and rasterization run on `cosmic-text` +
-//! `swash`, the same stack the `gpui_wgpu` text system already links, so the
-//! app keeps a single font stack and a single `cosmic-text` in the lockfile.
-//!
-//! [`RealEngine::new`](super::real::RealEngine::new) calls [`install`] once;
-//! [`font_families`] is the other entry point, feeding the
-//! `font_family_in` combo of the text nodes (see
-//! [`super::effectchain::effect_params`]).
-//!
-//! # Units
-//!
-//! [`TextLayoutRequest::dots_per_meter`] is the paint device resolution
-//! (Qt's `QTextDocument`/`QPainter` device metric); font sizes and geometry
-//! in the request are points, and the laid-out document the hooks return and
-//! draw in is sized in *device pixels*, exactly like the `QTextDocument`
-//! this replaced. The conversion is `px = pt * dots_per_meter * 0.0254 / 72`
-//! (3780 dots/meter, the Qt default for 96 DPI, gives the familiar 4/3
-//! factor); a zero or negative `dots_per_meter` falls back to 3780 and a
-//! non-positive or non-finite `font_size_pt` to 72 pt. Both fallbacks match
-//! the text nodes' own defaults (`oak_node::nodes::textv3`'s
-//! `font_size_in` default is 72 pt).
-//!
-//! # Approximations compared to the former Qt implementation
-//!
-//! * Line spacing is `1.2 * font_size` (Qt's single spacing depends on the
-//! font's own metrics).
-//! * `Html` / `OliveHtml` requests are flattened by [`html_to_plain`]
-//! instead of being laid out as rich text: tags carry no formatting here,
-//! and the text is painted white throughout — per-span colors, bold /
-//! italic runs and the rich-text alignment / list layout of the old
-//! `QTextDocument` are not reproduced.
-//! * Text decorations (underline / strikethrough) are not painted.
-//! * `center_horizontally` maps to [`Align::Center`] over the wrap width;
-//! like the C++ default `QTextOption(Qt::AlignCenter)` it is a no-op when
-//! the request carries no wrap width.
-//! * A request whose flattened text is empty measures 0×0 and paints
-//! nothing (an empty `QTextDocument` reports one empty line's height).
-//!
-//! The layout and rasterization themselves follow the C++ contract to the
-//! pixel: a document point `p` lands at
-//! `((p.x + draw_offset_x) * scale, (p.y + draw_offset_y) * scale)`, clipped
-//! to the scaled clip rect, and the two target formats are the Qt
-//! `Format_Grayscale8` coverage buffer (channel count 1) and the
-//! `Format_RGBA8888_Premultiplied` buffer (channel count 4).
+//! The text engine re-export: the implementation lives in
+//! [`oak_render::textengine`] so the app AND the worker pool share one
+//! installation (the workers render the text nodes — installing the
+//! hooks only in the UI process left text invisible in every preview).
-use std::borrow::Cow;
-use std::sync::{Mutex, MutexGuard, Once, OnceLock};
-
-use cosmic_text::{
- Align, Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, SwashCache, Wrap,
-};
-use oak_node::nodes::textbackend::{
- set_text_backends, TextLayoutMode, TextLayoutRequest, TextLayoutSize, TextRenderTarget,
- TextRenderTransform,
-};
-
-/// Fallback font size in points (the text nodes' `font_size_in` default).
-const DEFAULT_FONT_SIZE_PT: f64 = 72.0;
-
-/// Fallback paint device resolution: 3780 dots/meter ≈ 96 DPI, Qt's default.
-const DEFAULT_DOTS_PER_METER: f64 = 3780.0;
-
-/// Fallback font size in device pixels, used only when the point size and
-/// the resolution cannot produce a usable pixel size.
-const DEFAULT_FONT_PX: f32 = 96.0;
-
-/// Points per inch (the numerator of the point → pixel conversion).
-const POINTS_PER_INCH: f64 = 72.0;
-
-/// Meters per inch (the denominator of the point → pixel conversion).
-const METERS_PER_INCH: f64 = 0.0254;
-
-/// Line height as a multiple of the font size.
-const LINE_HEIGHT_SCALE: f32 = 1.2;
-
-/// Upper bound on the rasterized font size. A project file can carry an
-/// arbitrary `font_size_pt` / `dots_per_meter`; without a ceiling the swash
-/// bitmap cache would happily try to allocate gigabytes for a glyph.
-const MAX_FONT_PX: f32 = 8192.0;
-
-/// Upper bound on the wrap width, keeping the layout solver's inputs sane
-/// for a corrupt `wrap_width`.
-const MAX_WRAP_PX: f32 = 1.0e6;
-
-/// Glyph rasterization state shared by every request.
-///
-/// [`FontSystem`] scans the system font directories on construction, so it
-/// is built once (lazily, on the first non-empty request) and reused; a
-/// [`Buffer`] is cheap and stays per-request.
-struct TextSystem {
- font_system: FontSystem,
- swash_cache: SwashCache,
-}
-
-/// The process-wide text system.
-fn system() -> &'static Mutex {
- static SYSTEM: OnceLock> = OnceLock::new();
- SYSTEM.get_or_init(|| {
- Mutex::new(TextSystem {
- font_system: FontSystem::new(),
- swash_cache: SwashCache::new(),
- })
- })
-}
-
-/// Locks the text system, recovering from a poisoned lock: a panic while
-/// shaping one text node must not take the whole app's text rendering down.
-fn lock_system() -> MutexGuard<'static, TextSystem> {
- system().lock().unwrap_or_else(|e| e.into_inner())
-}
-
-/// Installs this module's hooks as the process-wide text backends (C++
-/// `set_text_backends()` at app startup).
-///
-/// Idempotent: only the first call installs, so a second engine instance
-/// cannot swap the hooks out from under a layout in flight.
-pub fn install() {
- static INSTALL: Once = Once::new();
- INSTALL.call_once(|| {
- set_text_backends(Some(measure), Some(render));
- });
-}
-
-/// Measure hook: lays the request out and returns the document size in
-/// device pixels (C++ `QTextDocument::size()`).
-pub fn measure(req: &TextLayoutRequest) -> TextLayoutSize {
- let text = request_text(req);
- if text.is_empty() {
- return TextLayoutSize::default();
- }
- let mut sys = lock_system();
- let buffer = layout(&mut sys.font_system, req, &text);
- let mut width = 0.0f32;
- let mut height = 0.0f32;
- for run in buffer.layout_runs() {
- width = width.max(run.line_w);
- height = height.max(run.line_top + run.line_height);
- }
- TextLayoutSize {
- width: width as f64,
- height: height as f64,
- }
-}
-
-/// Render hook: paints the request into `target` (C++
-/// `QAbstractTextDocumentLayout::draw()`).
-pub fn render(req: &TextLayoutRequest, transform: &TextRenderTransform, target: TextRenderTarget) {
- let TextRenderTarget {
- data,
- width,
- height,
- linesize_bytes,
- channel_count,
- } = target;
- if width <= 0 || height <= 0 || linesize_bytes <= 0 || !matches!(channel_count, 1 | 4) {
- return;
- }
- // `QPainter::scale(0, 0)` (degenerate transform in the stored
- // parameters) paints nothing rather than collapsing to a matrix.
- let scale = transform.scale;
- if !scale.is_finite() || scale <= 0.0 {
- return;
- }
- let text = request_text(req);
- if text.is_empty() {
- return;
- }
- let (clip_left, clip_top, clip_right, clip_bottom) =
- clip_rect(transform, width, height, scale);
- if !(clip_left < clip_right && clip_top < clip_bottom) {
- return;
- }
-
- let mut sys = lock_system();
- let mut buffer = layout(&mut sys.font_system, req, &text);
- let TextSystem {
- font_system,
- swash_cache,
- } = &mut *sys;
- let offset_x = transform.draw_offset_x * scale;
- for run in buffer.layout_runs() {
- let offset_y = (run.line_y as f64 + transform.draw_offset_y) * scale;
- for glyph in run.glyphs {
- let physical = glyph.physical((offset_x as f32, offset_y as f32), scale as f32);
- // The raster extends about one em around the glyph origin in
- // both axes; skip the glyphs that cannot touch the clip rect
- // instead of letting swash rasterize (and cache) them.
- let em = glyph.font_size as f64 * scale;
- let gx = physical.x as f64;
- let gy = physical.y as f64;
- if gx + em < clip_left
- || gx - em > clip_right
- || gy + em < clip_top
- || gy - em > clip_bottom
- {
- continue;
- }
- let base = glyph.color_opt.unwrap_or(WHITE);
- swash_cache.with_pixels(font_system, physical.cache_key, base, |px, py, color| {
- let x = physical.x + px;
- let y = physical.y + py;
- if (x as f64) < clip_left
- || (x as f64) >= clip_right
- || (y as f64) < clip_top
- || (y as f64) >= clip_bottom
- {
- return;
- }
- blend(data, linesize_bytes, channel_count, x, y, color);
- });
- }
- }
-}
-
-/// The default text color: the backends always paint white unless the
-/// markup overrides it (C++ `QPalette::Text` = `Qt::white`).
-const WHITE: Color = Color::rgb(0xFF, 0xFF, 0xFF);
-
-/// The sorted, de-duplicated font families of the system font database.
-///
-/// Feeds the text nodes' `font_family_in` combo (the `combo_option`
-/// injection in [`super::effectchain::effect_params`]). Names are the
-/// English family names, so they match what a project stores; an empty
-/// database yields an empty list and the combo keeps free-form entry.
-///
-/// The list is snapshotted on first use: `effect_params` rebuilds the
-/// inspector's parameters on every engine change, and the font database
-/// only changes when fonts are installed (an app restart).
-pub fn font_families() -> Vec {
- static FAMILIES: OnceLock> = OnceLock::new();
- FAMILIES
- .get_or_init(|| {
- let mut names: Vec = {
- let sys = lock_system();
- sys.font_system
- .db()
- .faces()
- .filter_map(|face| face.families.first().map(|(name, _)| name.clone()))
- .collect()
- };
- names.sort();
- names.dedup();
- names
- })
- .clone()
-}
-
-/// The pixel-space clip rectangle of a render, already intersected with the
-/// target buffer. An empty (or inverted) rectangle means nothing is drawn;
-/// a non-finite rect from a corrupt transform collapses to empty too.
-fn clip_rect(
- transform: &TextRenderTransform,
- width: i32,
- height: i32,
- scale: f64,
-) -> (f64, f64, f64, f64) {
- let mut left = 0.0f64;
- let mut top = 0.0f64;
- let mut right = width as f64;
- let mut bottom = height as f64;
- if transform.clip_enabled {
- let x = transform.clip_offset_x * scale;
- let y = transform.clip_offset_y * scale;
- let w = transform.clip_width * scale;
- let h = transform.clip_height * scale;
- if !(x.is_finite() && y.is_finite() && w.is_finite() && h.is_finite()) {
- return (0.0, 0.0, 0.0, 0.0);
- }
- left = left.max(x);
- top = top.max(y);
- right = right.min(x + w.max(0.0));
- bottom = bottom.min(y + h.max(0.0));
- }
- (left, top, right, bottom)
-}
-
-/// Composites one source pixel over the target.
-///
-/// `channel_count == 1` is the grayscale coverage buffer the v1/v2 nodes
-/// tint afterwards: the glyph contributes its alpha as coverage. Channel
-/// count 4 is the premultiplied RGBA buffer of v3, so the source is
-/// premultiplied before the over-blend (the hook's colors are straight —
-/// swash's mask pixels carry the (white) base color plus coverage-as-alpha,
-/// and its color bitmaps are straight RGBA).
-fn blend(
- data: &mut [u8],
- linesize_bytes: i32,
- channel_count: i32,
- x: i32,
- y: i32,
- color: Color,
-) {
- if x < 0 || y < 0 {
- return;
- }
- let alpha = color.a();
- if alpha == 0 {
- return;
- }
- let offset = y as usize * linesize_bytes as usize + x as usize * channel_count as usize;
- if channel_count == 1 {
- let Some(dst) = data.get_mut(offset) else {
- return;
- };
- let src = alpha as u32;
- let out = src + (*dst as u32 * (255 - src)) / 255;
- *dst = out.min(255) as u8;
- return;
- }
- let Some(pixel) = data.get_mut(offset..offset + 4) else {
- return;
- };
- let src_alpha = alpha as u32;
- let inverse = 255 - src_alpha;
- let src = [
- (color.r() as u32 * src_alpha + 127) / 255,
- (color.g() as u32 * src_alpha + 127) / 255,
- (color.b() as u32 * src_alpha + 127) / 255,
- src_alpha,
- ];
- for (dst, value) in pixel.iter_mut().zip(src) {
- *dst = (value + *dst as u32 * inverse / 255).min(255) as u8;
- }
-}
-
-/// The text a request lays out: the request's own text, or the flattened
-/// markup for the two HTML modes.
-fn request_text(req: &TextLayoutRequest) -> Cow<'_, str> {
- match req.mode {
- TextLayoutMode::PlainText => Cow::Borrowed(req.text.as_str()),
- TextLayoutMode::Html | TextLayoutMode::OliveHtml => Cow::Owned(html_to_plain(&req.text)),
- }
-}
-
-/// Lays a request out into a shaped [`Buffer`].
-fn layout(font_system: &mut FontSystem, req: &TextLayoutRequest, text: &str) -> Buffer {
- let metrics = Metrics::relative(font_px(req), LINE_HEIGHT_SCALE);
- let mut buffer = Buffer::new(font_system, metrics);
- let mut attrs = Attrs::new();
- if !req.font_family.is_empty() {
- attrs = attrs.family(Family::Name(req.font_family.as_str()));
- }
- let alignment = if req.center_horizontally {
- Some(Align::Center)
- } else {
- None
- };
- buffer.set_text(text, &attrs, Shaping::Advanced, alignment);
- if req.wrap_width.is_finite() && req.wrap_width > 0.0 {
- buffer.set_size(Some(req.wrap_width.min(MAX_WRAP_PX as f64) as f32), None);
- }
- // CJK text has no spaces to break at, so word wrapping alone would
- // overflow the wrap width; `WordOrGlyph` keeps the C++ behavior of
- // wrapping inside a run of CJK.
- buffer.set_wrap(Wrap::WordOrGlyph);
- buffer.shape_until_scroll(font_system, false);
- buffer
-}
-
-/// The font size of a request in device pixels, with the documented
-/// fallbacks and a sanity ceiling.
-fn font_px(req: &TextLayoutRequest) -> f32 {
- let pt = if req.font_size_pt.is_finite() && req.font_size_pt > 0.0 {
- req.font_size_pt
- } else {
- DEFAULT_FONT_SIZE_PT
- };
- let dots_per_meter = if req.dots_per_meter > 0 {
- req.dots_per_meter as f64
- } else {
- DEFAULT_DOTS_PER_METER
- };
- let px = pt * dots_per_meter * (METERS_PER_INCH / POINTS_PER_INCH);
- if !px.is_finite() || px <= 0.0 {
- return DEFAULT_FONT_PX;
- }
- (px as f32).min(MAX_FONT_PX)
-}
-
-/// Flattens the HTML the text nodes may carry into plain text.
-///
-/// This mirrors `oak_node::nodes::textv3`'s legacy-HTML stripper (which
-/// is crate-private): tags are dropped without being rescanned, the
-/// entities it knows are decoded, unknown entities and bare `&` are kept
-/// as-is, and whitespace is neither collapsed nor trimmed. On top of that,
-/// `
` and the block-level end tags (`
`, ``, ``, the
-/// headings, table rows, …) become line breaks so the paragraph structure
-/// of `Html` / `OliveHtml` text survives flattening; the breaks a trailing
-/// block end tag would add are dropped.
-fn html_to_plain(html: &str) -> String {
- /// Whether `chars` starts with the (ASCII) `entity` text.
- fn starts_with(chars: &[char], entity: &str) -> bool {
- let mut it = chars.iter();
- entity.chars().all(|c| it.next() == Some(&c))
- }
-
- const ENTITIES: [(&str, &str); 6] = [
- ("&", "&"),
- ("<", "<"),
- (">", ">"),
- (""", "\""),
- ("'", "'"),
- (" ", " "),
- ];
-
- /// Block-level end tags that start a new line, plus `
` itself.
- const LINE_BREAK_TAGS: [&str; 21] = [
- "br",
- "/p",
- "/div",
- "/li",
- "/tr",
- "/h1",
- "/h2",
- "/h3",
- "/h4",
- "/h5",
- "/h6",
- "/blockquote",
- "/pre",
- "/table",
- "/ul",
- "/ol",
- "/dl",
- "/dt",
- "/dd",
- "/section",
- "/figure",
- ];
-
- let chars: Vec = html.chars().collect();
- let mut out = String::with_capacity(html.len());
- let mut i = 0;
- while i < chars.len() {
- match chars[i] {
- '<' => {
- // Drop up to and including the tag's closing '>'; an
- // unterminated tag drops the remainder. The tag text is
- // discarded, never rescanned, so a decoded `<p>`
- // cannot turn into a tag afterwards.
- let start = i + 1;
- let mut end = start;
- while end < chars.len() && chars[end] != '>' {
- end += 1;
- }
- let tag: String = chars[start..end].iter().collect::().to_lowercase();
- let trimmed = tag.trim_start();
- let (closing, rest) = match trimmed.strip_prefix('/') {
- Some(rest) => (true, rest.trim_start()),
- None => (false, trimmed),
- };
- let name: String = rest
- .chars()
- .take_while(|c| c.is_ascii_alphanumeric())
- .collect();
- let name = if closing { format!("/{name}") } else { name };
- if !out.is_empty() && LINE_BREAK_TAGS.contains(&name.as_str()) {
- out.push('\n');
- }
- i = end + 1;
- }
- '&' => {
- let decoded = ENTITIES
- .iter()
- .find(|(entity, _)| starts_with(&chars[i..], entity));
- match decoded {
- Some((entity, replacement)) => {
- out.push_str(replacement);
- i += entity.chars().count();
- }
- None => {
- // Unknown entity (or a bare '&'): keep it as-is.
- out.push('&');
- i += 1;
- }
- }
- }
- c => {
- out.push(c);
- i += 1;
- }
- }
- }
- while out.ends_with('\n') {
- out.pop();
- }
- out
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use std::sync::Mutex as StdMutex;
-
- /// The hook statics are process-global; the one test that installs them
- /// holds this so it cannot race a future one.
- static HOOK_LOCK: StdMutex<()> = StdMutex::new(());
-
- /// A plain-text request with the test defaults (72 pt at 3780
- /// dots/meter, no wrap, no centering, backend default family).
- fn plain(text: &str, font_size_pt: f64, wrap_width: f64) -> TextLayoutRequest {
- TextLayoutRequest {
- text: text.to_string(),
- mode: TextLayoutMode::PlainText,
- font_family: String::new(),
- font_size_pt,
- dots_per_meter: 3780,
- wrap_width,
- center_horizontally: false,
- }
- }
-
- /// A transform with only the scale set (the rest neutral).
- fn scaled(scale: f64) -> TextRenderTransform {
- TextRenderTransform {
- scale,
- ..TextRenderTransform::default()
- }
- }
-
- /// Renders a request into a zeroed buffer and returns it.
- fn render_into(
- req: &TextLayoutRequest,
- transform: &TextRenderTransform,
- width: i32,
- height: i32,
- channel_count: i32,
- ) -> Vec {
- let mut data = vec![0u8; (width * height * channel_count) as usize];
- render(
- req,
- transform,
- TextRenderTarget {
- data: &mut data,
- width,
- height,
- linesize_bytes: width * channel_count,
- channel_count,
- },
- );
- data
- }
-
- /// Bounding box (`min_x`, `min_y`, `max_x`, `max_y`) of the nonzero
- /// pixels of a grayscale buffer; `None` when nothing was painted.
- fn ink_bounds(data: &[u8], width: i32, height: i32) -> Option<(i32, i32, i32, i32)> {
- let mut bounds: Option<(i32, i32, i32, i32)> = None;
- for y in 0..height {
- for x in 0..width {
- if data[(y * width + x) as usize] != 0 {
- bounds = Some(match bounds {
- None => (x, y, x, y),
- Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x), y1.max(y)),
- });
- }
- }
- }
- bounds
- }
-
- fn has_ink(data: &[u8]) -> bool {
- data.iter().any(|b| *b != 0)
- }
-
- #[test]
- fn plain_text_measures_nonzero() {
- let size = measure(&plain("Hello", 72.0, 0.0));
- assert!(size.width > 0.0, "width = {}", size.width);
- assert!(size.height > 0.0, "height = {}", size.height);
- }
-
- #[test]
- fn measure_scales_with_font_size() {
- let small = measure(&plain("Hello", 36.0, 0.0));
- let large = measure(&plain("Hello", 72.0, 0.0));
- assert!(large.width > small.width * 1.5);
- assert!(large.height > small.height * 1.5);
- }
-
- #[test]
- fn cjk_text_measures_nonzero() {
- // No explicit family: the default fallback chain has to resolve the
- // glyphs through fontconfig.
- let size = measure(&plain("中文字体测试", 72.0, 0.0));
- assert!(size.width > 0.0, "width = {}", size.width);
- assert!(size.height > 0.0, "height = {}", size.height);
- }
-
- #[test]
- fn empty_text_measures_zero() {
- let size = measure(&plain("", 72.0, 0.0));
- assert_eq!(size.width, 0.0);
- assert_eq!(size.height, 0.0);
- }
-
- #[test]
- fn multi_line_is_taller_than_single_line() {
- let one = measure(&plain("Hello", 72.0, 0.0));
- let two = measure(&plain("Hello\nWorld", 72.0, 0.0));
- assert!(two.height > one.height);
- }
-
- #[test]
- fn wrap_width_wraps() {
- let unwrapped = measure(&plain("中文字体测试", 72.0, 0.0));
- let wrapped = measure(&plain("中文字体测试", 72.0, 200.0));
- assert!(
- wrapped.width < unwrapped.width,
- "wrapped {} >= unwrapped {}",
- wrapped.width,
- unwrapped.width
- );
- assert!(wrapped.width <= 200.0, "wrapped width {}", wrapped.width);
- assert!(wrapped.height > unwrapped.height);
- }
-
- #[test]
- fn html_mode_flattens_tags() {
- let plain_req = plain("Hello", 72.0, 0.0);
- let html_req = TextLayoutRequest {
- text: "Hello".to_string(),
- mode: TextLayoutMode::Html,
- ..plain("Hello", 72.0, 0.0)
- };
- assert_eq!(measure(&html_req).width, measure(&plain_req).width);
- assert_eq!(measure(&html_req).height, measure(&plain_req).height);
- }
-
- #[test]
- fn html_to_plain_decodes_and_breaks() {
- assert_eq!(html_to_plain("a"), "a");
- assert_eq!(html_to_plain("a
b"), "a\nb");
- assert_eq!(html_to_plain("a
b"), "a\nb");
- assert_eq!(html_to_plain("a
b
"), "a\nb");
- assert_eq!(html_to_plain("ab"), "a\nb");
- assert_eq!(html_to_plain("&<>"' "), "&<>\"' ");
- // Unknown entities and bare `&` survive untouched.
- assert_eq!(html_to_plain("a &b"), "a &b");
- // A decoded `<p>` is text, never rescanned into a tag.
- assert_eq!(html_to_plain("<p>"), "");
- // Unterminated tags drop the remainder; trailing breaks are dropped.
- assert_eq!(html_to_plain("
a"), "a");
- assert_eq!(html_to_plain("a
"), "a");
- }
-
- #[test]
- fn font_families_are_sorted_and_unique() {
- let families = font_families();
- assert!(
- !families.is_empty(),
- "the system font database has no family"
- );
- let mut sorted = families.clone();
- sorted.sort();
- sorted.dedup();
- assert_eq!(families, sorted);
- // Cached: the second call hands out the same list.
- assert_eq!(font_families(), families);
- }
-
- #[test]
- fn font_pixel_size_fallbacks() {
- let base = plain("x", 72.0, 0.0);
- // 72 pt at 3780 dots/meter == 96 DPI == 96.012 px.
- assert!((font_px(&base) as f64 - 96.012).abs() < 0.01);
- // 0 / non-finite point sizes fall back to 72 pt.
- assert_eq!(font_px(&plain("x", 0.0, 0.0)), font_px(&base));
- assert_eq!(font_px(&plain("x", f64::NAN, 0.0)), font_px(&base));
- // Non-positive dots-per-meter falls back to 3780.
- let mut req = plain("x", 72.0, 0.0);
- req.dots_per_meter = 0;
- assert_eq!(font_px(&req), font_px(&base));
- req.dots_per_meter = -3780;
- assert_eq!(font_px(&req), font_px(&base));
- // Absurd sizes clamp instead of asking swash for a huge bitmap.
- assert_eq!(font_px(&plain("x", 1.0e9, 0.0)), MAX_FONT_PX);
- }
-
- #[test]
- fn render_writes_premultiplied_white_rgba() {
- let data = render_into(&plain("H", 72.0, 0.0), &scaled(1.0), 160, 160, 4);
- let mut ink = 0usize;
- for px in data.chunks_exact(4) {
- let (r, g, b, a) = (px[0], px[1], px[2], px[3]);
- if a == 0 && r == 0 && g == 0 && b == 0 {
- continue;
- }
- ink += 1;
- assert_eq!((r, g, b), (r, r, r), "non-white pixel {px:?}");
- assert!(r <= a, "not premultiplied: {px:?}");
- }
- assert!(ink > 0, "the render painted nothing");
- }
-
- #[test]
- fn render_writes_grayscale_coverage() {
- let req = plain("H", 72.0, 0.0);
- let gray = render_into(&req, &scaled(1.0), 160, 160, 1);
- assert!(has_ink(&gray), "the render painted nothing");
- // The RGBA alpha is that same coverage (both blend over a zeroed
- // buffer), so the two formats have to agree pixel for pixel.
- let rgba = render_into(&req, &scaled(1.0), 160, 160, 4);
- for (i, alpha) in rgba.chunks_exact(4).map(|px| px[3]).enumerate() {
- assert_eq!(gray[i], alpha, "coverage mismatch at pixel {i}");
- }
- }
-
- #[test]
- fn render_scales_the_glyph() {
- let req = plain("H", 72.0, 0.0);
- let one = render_into(&req, &scaled(1.0), 256, 256, 1);
- let two = render_into(&req, &scaled(2.0), 256, 256, 1);
- let (x0, y0, x1, y1) = ink_bounds(&one, 256, 256).expect("scale 1 ink");
- let (u0, v0, u1, v1) = ink_bounds(&two, 256, 256).expect("scale 2 ink");
- assert!((u1 - u0) > (x1 - x0) * 3 / 2, "width did not scale");
- assert!((v1 - v0) > (y1 - y0) * 3 / 2, "height did not scale");
- }
-
- #[test]
- fn render_skips_degenerate_scale() {
- for scale in [0.0, -1.0, f64::NAN, f64::INFINITY] {
- let data = render_into(&plain("H", 72.0, 0.0), &scaled(scale), 64, 64, 1);
- assert!(!has_ink(&data), "scale {scale} painted something");
- }
- }
-
- #[test]
- fn render_clips_to_the_clip_rect() {
- let req = plain("H", 72.0, 0.0);
- let full = render_into(&req, &scaled(1.0), 160, 160, 1);
- let (min_x, _, _, _) = ink_bounds(&full, 160, 160).expect("unclipped ink");
- assert!(min_x < 40, "the unclipped ink starts at {min_x}");
-
- // A clip that keeps only the right half of the glyph.
- let clipped = render_into(
- &req,
- &TextRenderTransform {
- clip_enabled: true,
- clip_offset_x: 40.0,
- clip_width: 200.0,
- clip_height: 200.0,
- ..scaled(1.0)
- },
- 160,
- 160,
- 1,
- );
- let (cmin_x, _, cmax_x, cmax_y) =
- ink_bounds(&clipped, 160, 160).expect("ink inside the clip");
- assert!(cmin_x >= 40, "ink left of the clip at {cmin_x}");
- assert!(cmin_x > min_x, "the clip removed nothing");
- assert!(cmax_x < 160 && cmax_y < 160);
-
- // An empty clip rect and a non-finite one paint nothing.
- let empty = render_into(
- &req,
- &TextRenderTransform {
- clip_enabled: true,
- clip_width: 0.0,
- clip_height: 0.0,
- ..scaled(1.0)
- },
- 160,
- 160,
- 1,
- );
- assert!(!has_ink(&empty));
- let nan = render_into(
- &req,
- &TextRenderTransform {
- clip_enabled: true,
- clip_offset_x: f64::NAN,
- clip_width: 64.0,
- clip_height: 64.0,
- ..scaled(1.0)
- },
- 160,
- 160,
- 1,
- );
- assert!(!has_ink(&nan));
- }
-
- #[test]
- fn render_honors_draw_offset() {
- let req = plain("H", 72.0, 0.0);
- let base = render_into(&req, &scaled(1.0), 256, 256, 1);
- let shifted = render_into(
- &req,
- &TextRenderTransform {
- draw_offset_x: 100.0,
- draw_offset_y: 100.0,
- ..scaled(1.0)
- },
- 256,
- 256,
- 1,
- );
- let (bx, by, _, _) = ink_bounds(&base, 256, 256).expect("unshifted ink");
- let (sx, sy, _, _) = ink_bounds(&shifted, 256, 256).expect("shifted ink");
- // The offset is applied in device pixels after the scale, so the
- // corners move by exactly the offset.
- assert_eq!((sx, sy), (bx + 100, by + 100));
- }
-
- #[test]
- fn hooks_install_and_drive_the_nodes() {
- let _guard = HOOK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
- use oak_node::nodes::textbackend::{text_measure_backend, text_render_backend};
-
- install();
- let measure_fn = text_measure_backend().expect("install() sets the measure hook");
- let render_fn = text_render_backend().expect("install() sets the render hook");
-
- let req = plain("Hello", 72.0, 0.0);
- let size = measure_fn(&req);
- assert!(size.width > 0.0 && size.height > 0.0);
-
- let mut data = vec![0u8; 192 * 192];
- render_fn(
- &req,
- &scaled(1.0),
- TextRenderTarget {
- data: &mut data,
- width: 192,
- height: 192,
- linesize_bytes: 192,
- channel_count: 1,
- },
- );
- assert!(has_ink(&data), "the render hook painted nothing");
-
- // Leave the process hooks uninstalled like `textbackend`'s own tests
- // do, so no later test depends on this module's state.
- set_text_backends(None, None);
- }
-}
+pub use oak_render::textengine::{font_families, install};
diff --git a/crates/oak-render/Cargo.toml b/crates/oak-render/Cargo.toml
index fd1bfb425..5ec065e22 100644
--- a/crates/oak-render/Cargo.toml
+++ b/crates/oak-render/Cargo.toml
@@ -31,6 +31,11 @@ naga = { version = "25", default-features = false, features = ["glsl-in", "wgsl-
ocio-rs = { version = "0.2", features = ["bundled"] }
# `std::error::Error` impls for the crate-internal error enum.
thiserror = "2"
+# The text layout/raster engine installed into oak-node's textbackend
+# hooks (src/textengine.rs) — shared by the app and the worker pool, so
+# both sides pin the same version as gpui's text system (one cosmic-text
+# in the lockfile).
+cosmic-text = "=0.19.0"
# M15 S1: physical-memory query for the process-pool worker-count policy
# (sysctlbyname on macOS / sysconf on Linux) in src/procpool.rs.
libc = "0.2"
diff --git a/crates/oak-render/src/lib.rs b/crates/oak-render/src/lib.rs
index af0db628b..1b02c5015 100644
--- a/crates/oak-render/src/lib.rs
+++ b/crates/oak-render/src/lib.rs
@@ -52,5 +52,6 @@ pub mod manager;
pub mod procpool;
pub mod scheduler;
pub mod shaderfx;
+pub mod textengine;
pub mod ticket;
pub mod worker;
diff --git a/crates/oak-render/src/textengine.rs b/crates/oak-render/src/textengine.rs
new file mode 100644
index 000000000..8897fe97b
--- /dev/null
+++ b/crates/oak-render/src/textengine.rs
@@ -0,0 +1,861 @@
+// Oak Video Editor - Non-Linear Video Editor
+// Copyright (C) 2026 Oak Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+//! cosmic-text backend for the text generator nodes: the facade-layer half
+//! of [`oak_node::nodes::textbackend`].
+//!
+//! `oak-node` deliberately links no font or shaping crate: the text
+//! generator nodes describe their job with the
+//! [`TextLayoutRequest`](oak_node::nodes::textbackend::TextLayoutRequest) /
+//! [`TextRenderTarget`](oak_node::nodes::textbackend::TextRenderTarget) PODs
+//! and call the two function-pointer hooks that [`install`] fills in. This
+//! module is that backend: layout and rasterization run on `cosmic-text` +
+//! `swash`, the same stack the `gpui_wgpu` text system already links, so the
+//! app keeps a single font stack and a single `cosmic-text` in the lockfile.
+//!
+//! [`RealEngine::new`](super::real::RealEngine::new) calls [`install`] once;
+//! [`font_families`] is the other entry point, feeding the
+//! `font_family_in` combo of the text nodes (see
+//! [`super::effectchain::effect_params`]).
+//!
+//! # Units
+//!
+//! [`TextLayoutRequest::dots_per_meter`] is the paint device resolution
+//! (Qt's `QTextDocument`/`QPainter` device metric); font sizes and geometry
+//! in the request are points, and the laid-out document the hooks return and
+//! draw in is sized in *device pixels*, exactly like the `QTextDocument`
+//! this replaced. The conversion is `px = pt * dots_per_meter * 0.0254 / 72`
+//! (3780 dots/meter, the Qt default for 96 DPI, gives the familiar 4/3
+//! factor); a zero or negative `dots_per_meter` falls back to 3780 and a
+//! non-positive or non-finite `font_size_pt` to 72 pt. Both fallbacks match
+//! the text nodes' own defaults (`oak_node::nodes::textv3`'s
+//! `font_size_in` default is 72 pt).
+//!
+//! # Approximations compared to the former Qt implementation
+//!
+//! * Line spacing is `1.2 * font_size` (Qt's single spacing depends on the
+//! font's own metrics).
+//! * `Html` / `OliveHtml` requests are flattened by [`html_to_plain`]
+//! instead of being laid out as rich text: tags carry no formatting here,
+//! and the text is painted white throughout — per-span colors, bold /
+//! italic runs and the rich-text alignment / list layout of the old
+//! `QTextDocument` are not reproduced.
+//! * Text decorations (underline / strikethrough) are not painted.
+//! * `center_horizontally` maps to [`Align::Center`] over the wrap width;
+//! like the C++ default `QTextOption(Qt::AlignCenter)` it is a no-op when
+//! the request carries no wrap width.
+//! * A request whose flattened text is empty measures 0×0 and paints
+//! nothing (an empty `QTextDocument` reports one empty line's height).
+//!
+//! The layout and rasterization themselves follow the C++ contract to the
+//! pixel: a document point `p` lands at
+//! `((p.x + draw_offset_x) * scale, (p.y + draw_offset_y) * scale)`, clipped
+//! to the scaled clip rect, and the two target formats are the Qt
+//! `Format_Grayscale8` coverage buffer (channel count 1) and the
+//! `Format_RGBA8888_Premultiplied` buffer (channel count 4).
+
+use std::borrow::Cow;
+use std::sync::{Mutex, MutexGuard, Once, OnceLock};
+
+use cosmic_text::{
+ Align, Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, SwashCache, Wrap,
+};
+use oak_node::nodes::textbackend::{
+ set_text_backends, TextLayoutMode, TextLayoutRequest, TextLayoutSize, TextRenderTarget,
+ TextRenderTransform,
+};
+
+/// Fallback font size in points (the text nodes' `font_size_in` default).
+const DEFAULT_FONT_SIZE_PT: f64 = 72.0;
+
+/// Fallback paint device resolution: 3780 dots/meter ≈ 96 DPI, Qt's default.
+const DEFAULT_DOTS_PER_METER: f64 = 3780.0;
+
+/// Fallback font size in device pixels, used only when the point size and
+/// the resolution cannot produce a usable pixel size.
+const DEFAULT_FONT_PX: f32 = 96.0;
+
+/// Points per inch (the numerator of the point → pixel conversion).
+const POINTS_PER_INCH: f64 = 72.0;
+
+/// Meters per inch (the denominator of the point → pixel conversion).
+const METERS_PER_INCH: f64 = 0.0254;
+
+/// Line height as a multiple of the font size.
+const LINE_HEIGHT_SCALE: f32 = 1.2;
+
+/// Upper bound on the rasterized font size. A project file can carry an
+/// arbitrary `font_size_pt` / `dots_per_meter`; without a ceiling the swash
+/// bitmap cache would happily try to allocate gigabytes for a glyph.
+const MAX_FONT_PX: f32 = 8192.0;
+
+/// Upper bound on the wrap width, keeping the layout solver's inputs sane
+/// for a corrupt `wrap_width`.
+const MAX_WRAP_PX: f32 = 1.0e6;
+
+/// Glyph rasterization state shared by every request.
+///
+/// [`FontSystem`] scans the system font directories on construction, so it
+/// is built once (lazily, on the first non-empty request) and reused; a
+/// [`Buffer`] is cheap and stays per-request.
+struct TextSystem {
+ font_system: FontSystem,
+ swash_cache: SwashCache,
+}
+
+/// The process-wide text system.
+fn system() -> &'static Mutex {
+ static SYSTEM: OnceLock> = OnceLock::new();
+ SYSTEM.get_or_init(|| {
+ Mutex::new(TextSystem {
+ font_system: FontSystem::new(),
+ swash_cache: SwashCache::new(),
+ })
+ })
+}
+
+/// Locks the text system, recovering from a poisoned lock: a panic while
+/// shaping one text node must not take the whole app's text rendering down.
+fn lock_system() -> MutexGuard<'static, TextSystem> {
+ system().lock().unwrap_or_else(|e| e.into_inner())
+}
+
+/// Installs this module's hooks as the process-wide text backends (C++
+/// `set_text_backends()` at app startup).
+///
+/// Idempotent: only the first call installs, so a second engine instance
+/// cannot swap the hooks out from under a layout in flight.
+pub fn install() {
+ static INSTALL: Once = Once::new();
+ INSTALL.call_once(|| {
+ set_text_backends(Some(measure), Some(render));
+ });
+}
+
+/// Measure hook: lays the request out and returns the document size in
+/// device pixels (C++ `QTextDocument::size()`).
+pub fn measure(req: &TextLayoutRequest) -> TextLayoutSize {
+ let text = request_text(req);
+ if text.is_empty() {
+ return TextLayoutSize::default();
+ }
+ let mut sys = lock_system();
+ let buffer = layout(&mut sys.font_system, req, &text);
+ let mut width = 0.0f32;
+ let mut height = 0.0f32;
+ for run in buffer.layout_runs() {
+ width = width.max(run.line_w);
+ height = height.max(run.line_top + run.line_height);
+ }
+ TextLayoutSize {
+ width: width as f64,
+ height: height as f64,
+ }
+}
+
+/// Render hook: paints the request into `target` (C++
+/// `QAbstractTextDocumentLayout::draw()`).
+pub fn render(req: &TextLayoutRequest, transform: &TextRenderTransform, target: TextRenderTarget) {
+ let TextRenderTarget {
+ data,
+ width,
+ height,
+ linesize_bytes,
+ channel_count,
+ } = target;
+ if width <= 0 || height <= 0 || linesize_bytes <= 0 || !matches!(channel_count, 1 | 4) {
+ return;
+ }
+ // `QPainter::scale(0, 0)` (degenerate transform in the stored
+ // parameters) paints nothing rather than collapsing to a matrix.
+ let scale = transform.scale;
+ if !scale.is_finite() || scale <= 0.0 {
+ return;
+ }
+ let text = request_text(req);
+ if text.is_empty() {
+ return;
+ }
+ let (clip_left, clip_top, clip_right, clip_bottom) =
+ clip_rect(transform, width, height, scale);
+ if !(clip_left < clip_right && clip_top < clip_bottom) {
+ return;
+ }
+
+ let mut sys = lock_system();
+ let mut buffer = layout(&mut sys.font_system, req, &text);
+ let TextSystem {
+ font_system,
+ swash_cache,
+ } = &mut *sys;
+ let offset_x = transform.draw_offset_x * scale;
+ for run in buffer.layout_runs() {
+ let offset_y = (run.line_y as f64 + transform.draw_offset_y) * scale;
+ for glyph in run.glyphs {
+ let physical = glyph.physical((offset_x as f32, offset_y as f32), scale as f32);
+ // The raster extends about one em around the glyph origin in
+ // both axes; skip the glyphs that cannot touch the clip rect
+ // instead of letting swash rasterize (and cache) them.
+ let em = glyph.font_size as f64 * scale;
+ let gx = physical.x as f64;
+ let gy = physical.y as f64;
+ if gx + em < clip_left
+ || gx - em > clip_right
+ || gy + em < clip_top
+ || gy - em > clip_bottom
+ {
+ continue;
+ }
+ let base = glyph.color_opt.unwrap_or(WHITE);
+ swash_cache.with_pixels(font_system, physical.cache_key, base, |px, py, color| {
+ let x = physical.x + px;
+ let y = physical.y + py;
+ if (x as f64) < clip_left
+ || (x as f64) >= clip_right
+ || (y as f64) < clip_top
+ || (y as f64) >= clip_bottom
+ {
+ return;
+ }
+ blend(data, linesize_bytes, channel_count, x, y, color);
+ });
+ }
+ }
+}
+
+/// The default text color: the backends always paint white unless the
+/// markup overrides it (C++ `QPalette::Text` = `Qt::white`).
+const WHITE: Color = Color::rgb(0xFF, 0xFF, 0xFF);
+
+/// The sorted, de-duplicated font families of the system font database.
+///
+/// Feeds the text nodes' `font_family_in` combo (the `combo_option`
+/// injection in [`super::effectchain::effect_params`]). Names are the
+/// English family names, so they match what a project stores; an empty
+/// database yields an empty list and the combo keeps free-form entry.
+///
+/// The list is snapshotted on first use: `effect_params` rebuilds the
+/// inspector's parameters on every engine change, and the font database
+/// only changes when fonts are installed (an app restart).
+pub fn font_families() -> Vec {
+ static FAMILIES: OnceLock> = OnceLock::new();
+ FAMILIES
+ .get_or_init(|| {
+ let mut names: Vec = {
+ let sys = lock_system();
+ sys.font_system
+ .db()
+ .faces()
+ .filter_map(|face| face.families.first().map(|(name, _)| name.clone()))
+ .collect()
+ };
+ names.sort();
+ names.dedup();
+ names
+ })
+ .clone()
+}
+
+/// The pixel-space clip rectangle of a render, already intersected with the
+/// target buffer. An empty (or inverted) rectangle means nothing is drawn;
+/// a non-finite rect from a corrupt transform collapses to empty too.
+fn clip_rect(
+ transform: &TextRenderTransform,
+ width: i32,
+ height: i32,
+ scale: f64,
+) -> (f64, f64, f64, f64) {
+ let mut left = 0.0f64;
+ let mut top = 0.0f64;
+ let mut right = width as f64;
+ let mut bottom = height as f64;
+ if transform.clip_enabled {
+ let x = transform.clip_offset_x * scale;
+ let y = transform.clip_offset_y * scale;
+ let w = transform.clip_width * scale;
+ let h = transform.clip_height * scale;
+ if !(x.is_finite() && y.is_finite() && w.is_finite() && h.is_finite()) {
+ return (0.0, 0.0, 0.0, 0.0);
+ }
+ left = left.max(x);
+ top = top.max(y);
+ right = right.min(x + w.max(0.0));
+ bottom = bottom.min(y + h.max(0.0));
+ }
+ (left, top, right, bottom)
+}
+
+/// Composites one source pixel over the target.
+///
+/// `channel_count == 1` is the grayscale coverage buffer the v1/v2 nodes
+/// tint afterwards: the glyph contributes its alpha as coverage. Channel
+/// count 4 is the premultiplied RGBA buffer of v3, so the source is
+/// premultiplied before the over-blend (the hook's colors are straight —
+/// swash's mask pixels carry the (white) base color plus coverage-as-alpha,
+/// and its color bitmaps are straight RGBA).
+fn blend(
+ data: &mut [u8],
+ linesize_bytes: i32,
+ channel_count: i32,
+ x: i32,
+ y: i32,
+ color: Color,
+) {
+ if x < 0 || y < 0 {
+ return;
+ }
+ let alpha = color.a();
+ if alpha == 0 {
+ return;
+ }
+ let offset = y as usize * linesize_bytes as usize + x as usize * channel_count as usize;
+ if channel_count == 1 {
+ let Some(dst) = data.get_mut(offset) else {
+ return;
+ };
+ let src = alpha as u32;
+ let out = src + (*dst as u32 * (255 - src)) / 255;
+ *dst = out.min(255) as u8;
+ return;
+ }
+ let Some(pixel) = data.get_mut(offset..offset + 4) else {
+ return;
+ };
+ let src_alpha = alpha as u32;
+ let inverse = 255 - src_alpha;
+ let src = [
+ (color.r() as u32 * src_alpha + 127) / 255,
+ (color.g() as u32 * src_alpha + 127) / 255,
+ (color.b() as u32 * src_alpha + 127) / 255,
+ src_alpha,
+ ];
+ for (dst, value) in pixel.iter_mut().zip(src) {
+ *dst = (value + *dst as u32 * inverse / 255).min(255) as u8;
+ }
+}
+
+/// The text a request lays out: the request's own text, or the flattened
+/// markup for the two HTML modes.
+fn request_text(req: &TextLayoutRequest) -> Cow<'_, str> {
+ match req.mode {
+ TextLayoutMode::PlainText => Cow::Borrowed(req.text.as_str()),
+ TextLayoutMode::Html | TextLayoutMode::OliveHtml => Cow::Owned(html_to_plain(&req.text)),
+ }
+}
+
+/// Lays a request out into a shaped [`Buffer`].
+fn layout(font_system: &mut FontSystem, req: &TextLayoutRequest, text: &str) -> Buffer {
+ let metrics = Metrics::relative(font_px(req), LINE_HEIGHT_SCALE);
+ let mut buffer = Buffer::new(font_system, metrics);
+ let mut attrs = Attrs::new();
+ if !req.font_family.is_empty() {
+ attrs = attrs.family(Family::Name(req.font_family.as_str()));
+ }
+ let alignment = if req.center_horizontally {
+ Some(Align::Center)
+ } else {
+ None
+ };
+ buffer.set_text(text, &attrs, Shaping::Advanced, alignment);
+ if req.wrap_width.is_finite() && req.wrap_width > 0.0 {
+ buffer.set_size(Some(req.wrap_width.min(MAX_WRAP_PX as f64) as f32), None);
+ }
+ // CJK text has no spaces to break at, so word wrapping alone would
+ // overflow the wrap width; `WordOrGlyph` keeps the C++ behavior of
+ // wrapping inside a run of CJK.
+ buffer.set_wrap(Wrap::WordOrGlyph);
+ buffer.shape_until_scroll(font_system, false);
+ buffer
+}
+
+/// The font size of a request in device pixels, with the documented
+/// fallbacks and a sanity ceiling.
+fn font_px(req: &TextLayoutRequest) -> f32 {
+ let pt = if req.font_size_pt.is_finite() && req.font_size_pt > 0.0 {
+ req.font_size_pt
+ } else {
+ DEFAULT_FONT_SIZE_PT
+ };
+ let dots_per_meter = if req.dots_per_meter > 0 {
+ req.dots_per_meter as f64
+ } else {
+ DEFAULT_DOTS_PER_METER
+ };
+ let px = pt * dots_per_meter * (METERS_PER_INCH / POINTS_PER_INCH);
+ if !px.is_finite() || px <= 0.0 {
+ return DEFAULT_FONT_PX;
+ }
+ (px as f32).min(MAX_FONT_PX)
+}
+
+/// Flattens the HTML the text nodes may carry into plain text.
+///
+/// This mirrors `oak_node::nodes::textv3`'s legacy-HTML stripper (which
+/// is crate-private): tags are dropped without being rescanned, the
+/// entities it knows are decoded, unknown entities and bare `&` are kept
+/// as-is, and whitespace is neither collapsed nor trimmed. On top of that,
+/// `
` and the block-level end tags (``, ``, ``, the
+/// headings, table rows, …) become line breaks so the paragraph structure
+/// of `Html` / `OliveHtml` text survives flattening; the breaks a trailing
+/// block end tag would add are dropped.
+fn html_to_plain(html: &str) -> String {
+ /// Whether `chars` starts with the (ASCII) `entity` text.
+ fn starts_with(chars: &[char], entity: &str) -> bool {
+ let mut it = chars.iter();
+ entity.chars().all(|c| it.next() == Some(&c))
+ }
+
+ const ENTITIES: [(&str, &str); 6] = [
+ ("&", "&"),
+ ("<", "<"),
+ (">", ">"),
+ (""", "\""),
+ ("'", "'"),
+ (" ", " "),
+ ];
+
+ /// Block-level end tags that start a new line, plus `
` itself.
+ const LINE_BREAK_TAGS: [&str; 21] = [
+ "br",
+ "/p",
+ "/div",
+ "/li",
+ "/tr",
+ "/h1",
+ "/h2",
+ "/h3",
+ "/h4",
+ "/h5",
+ "/h6",
+ "/blockquote",
+ "/pre",
+ "/table",
+ "/ul",
+ "/ol",
+ "/dl",
+ "/dt",
+ "/dd",
+ "/section",
+ "/figure",
+ ];
+
+ let chars: Vec = html.chars().collect();
+ let mut out = String::with_capacity(html.len());
+ let mut i = 0;
+ while i < chars.len() {
+ match chars[i] {
+ '<' => {
+ // Drop up to and including the tag's closing '>'; an
+ // unterminated tag drops the remainder. The tag text is
+ // discarded, never rescanned, so a decoded `<p>`
+ // cannot turn into a tag afterwards.
+ let start = i + 1;
+ let mut end = start;
+ while end < chars.len() && chars[end] != '>' {
+ end += 1;
+ }
+ let tag: String = chars[start..end].iter().collect::().to_lowercase();
+ let trimmed = tag.trim_start();
+ let (closing, rest) = match trimmed.strip_prefix('/') {
+ Some(rest) => (true, rest.trim_start()),
+ None => (false, trimmed),
+ };
+ let name: String = rest
+ .chars()
+ .take_while(|c| c.is_ascii_alphanumeric())
+ .collect();
+ let name = if closing { format!("/{name}") } else { name };
+ if !out.is_empty() && LINE_BREAK_TAGS.contains(&name.as_str()) {
+ out.push('\n');
+ }
+ i = end + 1;
+ }
+ '&' => {
+ let decoded = ENTITIES
+ .iter()
+ .find(|(entity, _)| starts_with(&chars[i..], entity));
+ match decoded {
+ Some((entity, replacement)) => {
+ out.push_str(replacement);
+ i += entity.chars().count();
+ }
+ None => {
+ // Unknown entity (or a bare '&'): keep it as-is.
+ out.push('&');
+ i += 1;
+ }
+ }
+ }
+ c => {
+ out.push(c);
+ i += 1;
+ }
+ }
+ }
+ while out.ends_with('\n') {
+ out.pop();
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::sync::Mutex as StdMutex;
+
+ /// The hook statics are process-global; the one test that installs them
+ /// holds this so it cannot race a future one.
+ static HOOK_LOCK: StdMutex<()> = StdMutex::new(());
+
+ /// A plain-text request with the test defaults (72 pt at 3780
+ /// dots/meter, no wrap, no centering, backend default family).
+ fn plain(text: &str, font_size_pt: f64, wrap_width: f64) -> TextLayoutRequest {
+ TextLayoutRequest {
+ text: text.to_string(),
+ mode: TextLayoutMode::PlainText,
+ font_family: String::new(),
+ font_size_pt,
+ dots_per_meter: 3780,
+ wrap_width,
+ center_horizontally: false,
+ }
+ }
+
+ /// A transform with only the scale set (the rest neutral).
+ fn scaled(scale: f64) -> TextRenderTransform {
+ TextRenderTransform {
+ scale,
+ ..TextRenderTransform::default()
+ }
+ }
+
+ /// Renders a request into a zeroed buffer and returns it.
+ fn render_into(
+ req: &TextLayoutRequest,
+ transform: &TextRenderTransform,
+ width: i32,
+ height: i32,
+ channel_count: i32,
+ ) -> Vec {
+ let mut data = vec![0u8; (width * height * channel_count) as usize];
+ render(
+ req,
+ transform,
+ TextRenderTarget {
+ data: &mut data,
+ width,
+ height,
+ linesize_bytes: width * channel_count,
+ channel_count,
+ },
+ );
+ data
+ }
+
+ /// Bounding box (`min_x`, `min_y`, `max_x`, `max_y`) of the nonzero
+ /// pixels of a grayscale buffer; `None` when nothing was painted.
+ fn ink_bounds(data: &[u8], width: i32, height: i32) -> Option<(i32, i32, i32, i32)> {
+ let mut bounds: Option<(i32, i32, i32, i32)> = None;
+ for y in 0..height {
+ for x in 0..width {
+ if data[(y * width + x) as usize] != 0 {
+ bounds = Some(match bounds {
+ None => (x, y, x, y),
+ Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x), y1.max(y)),
+ });
+ }
+ }
+ }
+ bounds
+ }
+
+ fn has_ink(data: &[u8]) -> bool {
+ data.iter().any(|b| *b != 0)
+ }
+
+ #[test]
+ fn plain_text_measures_nonzero() {
+ let size = measure(&plain("Hello", 72.0, 0.0));
+ assert!(size.width > 0.0, "width = {}", size.width);
+ assert!(size.height > 0.0, "height = {}", size.height);
+ }
+
+ #[test]
+ fn measure_scales_with_font_size() {
+ let small = measure(&plain("Hello", 36.0, 0.0));
+ let large = measure(&plain("Hello", 72.0, 0.0));
+ assert!(large.width > small.width * 1.5);
+ assert!(large.height > small.height * 1.5);
+ }
+
+ #[test]
+ fn cjk_text_measures_nonzero() {
+ // No explicit family: the default fallback chain has to resolve the
+ // glyphs through fontconfig.
+ let size = measure(&plain("中文字体测试", 72.0, 0.0));
+ assert!(size.width > 0.0, "width = {}", size.width);
+ assert!(size.height > 0.0, "height = {}", size.height);
+ }
+
+ #[test]
+ fn empty_text_measures_zero() {
+ let size = measure(&plain("", 72.0, 0.0));
+ assert_eq!(size.width, 0.0);
+ assert_eq!(size.height, 0.0);
+ }
+
+ #[test]
+ fn multi_line_is_taller_than_single_line() {
+ let one = measure(&plain("Hello", 72.0, 0.0));
+ let two = measure(&plain("Hello\nWorld", 72.0, 0.0));
+ assert!(two.height > one.height);
+ }
+
+ #[test]
+ fn wrap_width_wraps() {
+ let unwrapped = measure(&plain("中文字体测试", 72.0, 0.0));
+ let wrapped = measure(&plain("中文字体测试", 72.0, 200.0));
+ assert!(
+ wrapped.width < unwrapped.width,
+ "wrapped {} >= unwrapped {}",
+ wrapped.width,
+ unwrapped.width
+ );
+ assert!(wrapped.width <= 200.0, "wrapped width {}", wrapped.width);
+ assert!(wrapped.height > unwrapped.height);
+ }
+
+ #[test]
+ fn html_mode_flattens_tags() {
+ let plain_req = plain("Hello", 72.0, 0.0);
+ let html_req = TextLayoutRequest {
+ text: "Hello".to_string(),
+ mode: TextLayoutMode::Html,
+ ..plain("Hello", 72.0, 0.0)
+ };
+ assert_eq!(measure(&html_req).width, measure(&plain_req).width);
+ assert_eq!(measure(&html_req).height, measure(&plain_req).height);
+ }
+
+ #[test]
+ fn html_to_plain_decodes_and_breaks() {
+ assert_eq!(html_to_plain("a"), "a");
+ assert_eq!(html_to_plain("a
b"), "a\nb");
+ assert_eq!(html_to_plain("a
b"), "a\nb");
+ assert_eq!(html_to_plain("a
b
"), "a\nb");
+ assert_eq!(html_to_plain("ab"), "a\nb");
+ assert_eq!(html_to_plain("&<>"' "), "&<>\"' ");
+ // Unknown entities and bare `&` survive untouched.
+ assert_eq!(html_to_plain("a &b"), "a &b");
+ // A decoded `<p>` is text, never rescanned into a tag.
+ assert_eq!(html_to_plain("<p>"), "");
+ // Unterminated tags drop the remainder; trailing breaks are dropped.
+ assert_eq!(html_to_plain("
a"), "a");
+ assert_eq!(html_to_plain("a
"), "a");
+ }
+
+ #[test]
+ fn font_families_are_sorted_and_unique() {
+ let families = font_families();
+ assert!(
+ !families.is_empty(),
+ "the system font database has no family"
+ );
+ let mut sorted = families.clone();
+ sorted.sort();
+ sorted.dedup();
+ assert_eq!(families, sorted);
+ // Cached: the second call hands out the same list.
+ assert_eq!(font_families(), families);
+ }
+
+ #[test]
+ fn font_pixel_size_fallbacks() {
+ let base = plain("x", 72.0, 0.0);
+ // 72 pt at 3780 dots/meter == 96 DPI == 96.012 px.
+ assert!((font_px(&base) as f64 - 96.012).abs() < 0.01);
+ // 0 / non-finite point sizes fall back to 72 pt.
+ assert_eq!(font_px(&plain("x", 0.0, 0.0)), font_px(&base));
+ assert_eq!(font_px(&plain("x", f64::NAN, 0.0)), font_px(&base));
+ // Non-positive dots-per-meter falls back to 3780.
+ let mut req = plain("x", 72.0, 0.0);
+ req.dots_per_meter = 0;
+ assert_eq!(font_px(&req), font_px(&base));
+ req.dots_per_meter = -3780;
+ assert_eq!(font_px(&req), font_px(&base));
+ // Absurd sizes clamp instead of asking swash for a huge bitmap.
+ assert_eq!(font_px(&plain("x", 1.0e9, 0.0)), MAX_FONT_PX);
+ }
+
+ #[test]
+ fn render_writes_premultiplied_white_rgba() {
+ let data = render_into(&plain("H", 72.0, 0.0), &scaled(1.0), 160, 160, 4);
+ let mut ink = 0usize;
+ for px in data.chunks_exact(4) {
+ let (r, g, b, a) = (px[0], px[1], px[2], px[3]);
+ if a == 0 && r == 0 && g == 0 && b == 0 {
+ continue;
+ }
+ ink += 1;
+ assert_eq!((r, g, b), (r, r, r), "non-white pixel {px:?}");
+ assert!(r <= a, "not premultiplied: {px:?}");
+ }
+ assert!(ink > 0, "the render painted nothing");
+ }
+
+ #[test]
+ fn render_writes_grayscale_coverage() {
+ let req = plain("H", 72.0, 0.0);
+ let gray = render_into(&req, &scaled(1.0), 160, 160, 1);
+ assert!(has_ink(&gray), "the render painted nothing");
+ // The RGBA alpha is that same coverage (both blend over a zeroed
+ // buffer), so the two formats have to agree pixel for pixel.
+ let rgba = render_into(&req, &scaled(1.0), 160, 160, 4);
+ for (i, alpha) in rgba.chunks_exact(4).map(|px| px[3]).enumerate() {
+ assert_eq!(gray[i], alpha, "coverage mismatch at pixel {i}");
+ }
+ }
+
+ #[test]
+ fn render_scales_the_glyph() {
+ let req = plain("H", 72.0, 0.0);
+ let one = render_into(&req, &scaled(1.0), 256, 256, 1);
+ let two = render_into(&req, &scaled(2.0), 256, 256, 1);
+ let (x0, y0, x1, y1) = ink_bounds(&one, 256, 256).expect("scale 1 ink");
+ let (u0, v0, u1, v1) = ink_bounds(&two, 256, 256).expect("scale 2 ink");
+ assert!((u1 - u0) > (x1 - x0) * 3 / 2, "width did not scale");
+ assert!((v1 - v0) > (y1 - y0) * 3 / 2, "height did not scale");
+ }
+
+ #[test]
+ fn render_skips_degenerate_scale() {
+ for scale in [0.0, -1.0, f64::NAN, f64::INFINITY] {
+ let data = render_into(&plain("H", 72.0, 0.0), &scaled(scale), 64, 64, 1);
+ assert!(!has_ink(&data), "scale {scale} painted something");
+ }
+ }
+
+ #[test]
+ fn render_clips_to_the_clip_rect() {
+ let req = plain("H", 72.0, 0.0);
+ let full = render_into(&req, &scaled(1.0), 160, 160, 1);
+ let (min_x, _, _, _) = ink_bounds(&full, 160, 160).expect("unclipped ink");
+ assert!(min_x < 40, "the unclipped ink starts at {min_x}");
+
+ // A clip that keeps only the right half of the glyph.
+ let clipped = render_into(
+ &req,
+ &TextRenderTransform {
+ clip_enabled: true,
+ clip_offset_x: 40.0,
+ clip_width: 200.0,
+ clip_height: 200.0,
+ ..scaled(1.0)
+ },
+ 160,
+ 160,
+ 1,
+ );
+ let (cmin_x, _, cmax_x, cmax_y) =
+ ink_bounds(&clipped, 160, 160).expect("ink inside the clip");
+ assert!(cmin_x >= 40, "ink left of the clip at {cmin_x}");
+ assert!(cmin_x > min_x, "the clip removed nothing");
+ assert!(cmax_x < 160 && cmax_y < 160);
+
+ // An empty clip rect and a non-finite one paint nothing.
+ let empty = render_into(
+ &req,
+ &TextRenderTransform {
+ clip_enabled: true,
+ clip_width: 0.0,
+ clip_height: 0.0,
+ ..scaled(1.0)
+ },
+ 160,
+ 160,
+ 1,
+ );
+ assert!(!has_ink(&empty));
+ let nan = render_into(
+ &req,
+ &TextRenderTransform {
+ clip_enabled: true,
+ clip_offset_x: f64::NAN,
+ clip_width: 64.0,
+ clip_height: 64.0,
+ ..scaled(1.0)
+ },
+ 160,
+ 160,
+ 1,
+ );
+ assert!(!has_ink(&nan));
+ }
+
+ #[test]
+ fn render_honors_draw_offset() {
+ let req = plain("H", 72.0, 0.0);
+ let base = render_into(&req, &scaled(1.0), 256, 256, 1);
+ let shifted = render_into(
+ &req,
+ &TextRenderTransform {
+ draw_offset_x: 100.0,
+ draw_offset_y: 100.0,
+ ..scaled(1.0)
+ },
+ 256,
+ 256,
+ 1,
+ );
+ let (bx, by, _, _) = ink_bounds(&base, 256, 256).expect("unshifted ink");
+ let (sx, sy, _, _) = ink_bounds(&shifted, 256, 256).expect("shifted ink");
+ // The offset is applied in device pixels after the scale, so the
+ // corners move by exactly the offset.
+ assert_eq!((sx, sy), (bx + 100, by + 100));
+ }
+
+ #[test]
+ fn hooks_install_and_drive_the_nodes() {
+ let _guard = HOOK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ use oak_node::nodes::textbackend::{text_measure_backend, text_render_backend};
+
+ install();
+ let measure_fn = text_measure_backend().expect("install() sets the measure hook");
+ let render_fn = text_render_backend().expect("install() sets the render hook");
+
+ let req = plain("Hello", 72.0, 0.0);
+ let size = measure_fn(&req);
+ assert!(size.width > 0.0 && size.height > 0.0);
+
+ let mut data = vec![0u8; 192 * 192];
+ render_fn(
+ &req,
+ &scaled(1.0),
+ TextRenderTarget {
+ data: &mut data,
+ width: 192,
+ height: 192,
+ linesize_bytes: 192,
+ channel_count: 1,
+ },
+ );
+ assert!(has_ink(&data), "the render hook painted nothing");
+
+ // Leave the process hooks uninstalled like `textbackend`'s own tests
+ // do, so no later test depends on this module's state.
+ set_text_backends(None, None);
+ }
+}
diff --git a/crates/oak-worker/src/worker.rs b/crates/oak-worker/src/worker.rs
index dff630191..10663c7e3 100644
--- a/crates/oak-worker/src/worker.rs
+++ b/crates/oak-worker/src/worker.rs
@@ -358,6 +358,12 @@ impl WorkerSession {
"runtime: color-manager default config failed ({e}); continuing"
));
}
+ // The text nodes rasterize through the textbackend hooks: the pool
+ // renders text clips too, so every worker installs the same
+ // cosmic-text engine as the UI process (installing it only there
+ // left text invisible in previews — the hooks are per-process).
+ log_error("runtime: installing text layout backends");
+ oak_render::textengine::install();
// M15 S1: the plugin execution stack lives in the worker process
// (OFX crashes take down this process, not the editor — design
// §3.6). oakplugin installs its render driver into the oakrender