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.
This commit is contained in:
2026-09-11 08:47:33 +08:00
parent 4a2614b3fc
commit 19b7d3ac78
7 changed files with 879 additions and 850 deletions
Generated
+1 -1
View File
@@ -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",
-5
View File
@@ -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,
+5 -844
View File
@@ -14,848 +14,9 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! 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<TextSystem> {
static SYSTEM: OnceLock<Mutex<TextSystem>> = 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<String> {
static FAMILIES: OnceLock<Vec<String>> = OnceLock::new();
FAMILIES
.get_or_init(|| {
let mut names: Vec<String> = {
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,
/// `<br>` and the block-level end tags (`</p>`, `</div>`, `</li>`, 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] = [
("&amp;", "&"),
("&lt;", "<"),
("&gt;", ">"),
("&quot;", "\""),
("&#39;", "'"),
("&nbsp;", " "),
];
/// Block-level end tags that start a new line, plus `<br>` 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<char> = 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 `&lt;p&gt;`
// 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::<String>().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<u8> {
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: "<b>Hello</b>".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("<b>a</b>"), "a");
assert_eq!(html_to_plain("a<br>b"), "a\nb");
assert_eq!(html_to_plain("a<br/>b"), "a\nb");
assert_eq!(html_to_plain("<p>a</p><p>b</p>"), "a\nb");
assert_eq!(html_to_plain("a</DIV>b"), "a\nb");
assert_eq!(html_to_plain("&amp;&lt;&gt;&quot;&#39;&nbsp;"), "&<>\"' ");
// Unknown entities and bare `&` survive untouched.
assert_eq!(html_to_plain("a &b"), "a &b");
// A decoded `&lt;p&gt;` is text, never rescanned into a tag.
assert_eq!(html_to_plain("&lt;p&gt;"), "<p>");
// Unterminated tags drop the remainder; trailing breaks are dropped.
assert_eq!(html_to_plain("<p>a"), "a");
assert_eq!(html_to_plain("a</p>"), "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};
+5
View File
@@ -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"
+1
View File
@@ -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;
+861
View File
@@ -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 <http://www.gnu.org/licenses/>.
//! 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<TextSystem> {
static SYSTEM: OnceLock<Mutex<TextSystem>> = 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<String> {
static FAMILIES: OnceLock<Vec<String>> = OnceLock::new();
FAMILIES
.get_or_init(|| {
let mut names: Vec<String> = {
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,
/// `<br>` and the block-level end tags (`</p>`, `</div>`, `</li>`, 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] = [
("&amp;", "&"),
("&lt;", "<"),
("&gt;", ">"),
("&quot;", "\""),
("&#39;", "'"),
("&nbsp;", " "),
];
/// Block-level end tags that start a new line, plus `<br>` 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<char> = 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 `&lt;p&gt;`
// 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::<String>().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<u8> {
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: "<b>Hello</b>".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("<b>a</b>"), "a");
assert_eq!(html_to_plain("a<br>b"), "a\nb");
assert_eq!(html_to_plain("a<br/>b"), "a\nb");
assert_eq!(html_to_plain("<p>a</p><p>b</p>"), "a\nb");
assert_eq!(html_to_plain("a</DIV>b"), "a\nb");
assert_eq!(html_to_plain("&amp;&lt;&gt;&quot;&#39;&nbsp;"), "&<>\"' ");
// Unknown entities and bare `&` survive untouched.
assert_eq!(html_to_plain("a &b"), "a &b");
// A decoded `&lt;p&gt;` is text, never rescanned into a tag.
assert_eq!(html_to_plain("&lt;p&gt;"), "<p>");
// Unterminated tags drop the remainder; trailing breaks are dropped.
assert_eq!(html_to_plain("<p>a"), "a");
assert_eq!(html_to_plain("a</p>"), "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);
}
}
+6
View File
@@ -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