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