// 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 .
//! Rich text generator v3 (C++
//! `src/node/src/generator/text/textv3.{h,cpp}`,
//! `olive::TextGeneratorV3`, derives from `ShapeNodeBase`).
//!
//! FONT/RASTER BACKEND DEPENDENCY — DELIBERATELY UNDECIDED:
//! The C++ does NOT link any font/raster library directly (no freetype,
//! stb, harfbuzz, etc. anywhere in the tree). Text layout/rasterization
//! was historically Qt's rich text stack (`QTextDocument` fed through
//! Olive's `Html::html_to_doc()` + `QPainter` directly over an
//! RGBA8888-premultiplied buffer); it now runs behind the
//! facade-installed hooks in [`super::textbackend`]. No Rust font crate
//! is chosen here on purpose.
//!
//! REDESIGN (Rust-only, no C++ counterpart): on top of the ported v3
//! node this adds a structured, user-facing input set — `plain_text_in`,
//! `font_family_in`, `font_size_in` and the outline/glow enable/color/
//! size inputs — while the legacy `text_in` HTML input stays as the
//! serialized compatibility carrier (now hidden) and as the migration
//! source for pre-redesign projects ([`migrate_legacy_html`]). The
//! outline/glow inputs drive a GPU post-process chain in [`value`]: the
//! rasterized text coverage is dilated (square structuring element,
//! `outline_width_in` clamped to 0..7) and colorized into a stroke,
//! and/or blurred once per axis (two passes, `glow_radius_in` clamped
//! to 0..64) and colorized into a glow; the results are alpha-over'd
//! beneath the text. With both enabled the outline runs first and the
//! glow samples the stroke. With both disabled (or no render backend
//! installed) the node keeps the pre-redesign deferred null-job
//! behavior.
use crate::factory::NodeMeta;
use crate::jobs::{Job, ShaderJobPayload};
use crate::node::{Category, NodeBehavior, NodeCore};
use crate::value::{NodeValue, NodeValueRow, NodeValueTable};
use oak_core::frame::VideoParamsPod;
use oak_core::texture::{Frame, Texture};
use oak_core::Rational;
use super::textbackend::{TextLayoutMode, TextLayoutRequest, TextLayoutSize, TextRenderTransform};
/// Text input id (C++ `k_text_input`). Type: text; default
/// `LEGACY_DEFAULT_TEXT_HTML`; properties: `vieweronly = true`; flags:
/// hidden (REDESIGN: carried internally and kept for the serialized
/// compatibility of pre-redesign projects; the user-facing text is
/// [`PLAIN_TEXT_INPUT`]).
pub const TEXT_INPUT: &str = "text_in";
/// Vertical alignment input id (C++ `k_vertical_alignment_input`).
/// Type: combo; no default; flags: hidden | static; combo strings:
/// "Top", "Middle", "Bottom".
pub const VERTICAL_ALIGNMENT_INPUT: &str = "valign_in";
/// Args enable toggle input id (C++ `k_use_args_input`). Type: boolean;
/// default `true`; flags: hidden | static.
pub const USE_ARGS_INPUT: &str = "use_args_in";
/// Format arguments array input id (C++ `k_args_input`). Type: text;
/// flags: array; properties: `arraystart = 1`.
pub const ARGS_INPUT: &str = "args_in";
/// Plain text input id (REDESIGN addition, no C++ counterpart). Type:
/// text; default [`DEFAULT_PLAIN_TEXT`]. The editable user-facing text
/// and — when a text layout backend is installed — the text the
/// generator lays out; the legacy (hidden) [`TEXT_INPUT`] HTML stays as
/// the compatibility carrier.
pub const PLAIN_TEXT_INPUT: &str = "plain_text_in";
/// Font family input id (REDESIGN addition, no C++ counterpart). Type:
/// str-combo; default empty (the backend's default font). The option
/// list is injected by the backend layer (a `combo_option` property),
/// so this node has no [`TextGeneratorV3::input_combo_strings`] entry
/// for it; free-form entry is allowed.
pub const FONT_FAMILY_INPUT: &str = "font_family_in";
/// Font size input id (REDESIGN addition, no C++ counterpart). Type:
/// float; default `72.0`; properties: `min = 1.0`.
pub const FONT_SIZE_INPUT: &str = "font_size_in";
/// Outline enable toggle input id (REDESIGN addition, no C++
/// counterpart). Type: boolean; default `false`.
pub const OUTLINE_ENABLED_INPUT: &str = "outline_enabled_in";
/// Outline color input id (REDESIGN addition, no C++ counterpart).
/// Type: color; default opaque black.
pub const OUTLINE_COLOR_INPUT: &str = "outline_color_in";
/// Outline width input id (REDESIGN addition, no C++ counterpart).
/// Type: float; default `2.0`; properties: `min = 0.0`.
pub const OUTLINE_WIDTH_INPUT: &str = "outline_width_in";
/// Glow enable toggle input id (REDESIGN addition, no C++
/// counterpart). Type: boolean; default `false`.
pub const GLOW_ENABLED_INPUT: &str = "glow_enabled_in";
/// Glow color input id (REDESIGN addition, no C++ counterpart). Type:
/// color; default opaque yellow.
pub const GLOW_COLOR_INPUT: &str = "glow_color_in";
/// Glow radius input id (REDESIGN addition, no C++ counterpart). Type:
/// float; default `8.0`; properties: `min = 0.0`.
pub const GLOW_RADIUS_INPUT: &str = "glow_radius_in";
/// Font color input id (REDESIGN addition, no C++ counterpart). Type:
/// color; default opaque white (the backend rasterizes in white, so the
/// default leaves the raster unchanged). The glyphs are tinted with it
/// during rasterization, premultiplied — the same treatment the outline
/// and glow colorize passes give their own layers.
pub const COLOR_INPUT: &str = "color_in";
/// Default of the legacy [`TEXT_INPUT`] HTML payload (the C++
/// `k_text_input` default, verbatim). Used by [`create`] and by
/// [`migrate_legacy_html`] to recognize an untouched legacy value.
pub const LEGACY_DEFAULT_TEXT_HTML: &str =
"
Sample Text
";
/// Default of [`PLAIN_TEXT_INPUT`] (REDESIGN addition): the localized
/// placeholder text new text nodes start with.
pub const DEFAULT_PLAIN_TEXT: &str = "文本";
/// Vertical alignment (C++ `TextGeneratorV3::VerticalAlignment`, values
/// `k_v_align_top = 0`, `k_v_align_middle = 1`, `k_v_align_bottom = 2`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum VerticalAlignment {
/// Align to the top of the shape rect (C++ `k_v_align_top`).
Top,
/// Vertically center in the shape rect (C++ `k_v_align_middle`).
Middle,
/// Align to the bottom of the shape rect (C++ `k_v_align_bottom`).
Bottom,
}
impl VerticalAlignment {
/// From a combo index (C++ `static_cast`); unknown
/// values map to [`VerticalAlignment::Top`] like the C++ cast's
/// callers assume.
fn from_int(v: i32) -> VerticalAlignment {
match v {
1 => VerticalAlignment::Middle,
2 => VerticalAlignment::Bottom,
_ => VerticalAlignment::Top,
}
}
}
/// The C++ `k_input_flag_static` mask: not-connectable +
/// not-keyframable.
const STATIC_FLAGS: u32 =
crate::input::flags::NOT_CONNECTABLE | crate::input::flags::NOT_KEYFRAMABLE;
/// Rich text generator v3 (the current "Text" node). Inherits
/// position/size/color inputs and the polygon gizmo from the shape base
/// (C++ `ShapeNodeBase`, modelled in [`super::shapenodebase`]).
///
/// The C++ also owns a `TextGizmo *text_gizmo_` — a GUI-layer viewport
/// gizmo with no Rust equivalent in this crate; it is omitted here and
/// will be re-attached by the facade/gizmo wave (gizmos themselves live
/// in [`NodeCore::gizmos`]).
pub struct TextGeneratorV3 {
/// Suppresses re-emitting the vertical alignment to the gizmo
/// while it is being driven by the gizmo (C++ `dont_emit_valign_`).
dont_emit_valign: bool,
}
/// `Variant::to_string()` for the text inputs (Text and string-combo
/// payloads — the latter for [`FONT_FAMILY_INPUT`] — with a numeric
/// fallback for mis-typed connections).
fn to_text(v: &NodeValue) -> String {
match v {
NodeValue::Text(s) | NodeValue::StrCombo(s) => s.clone(),
other => other.to_double().to_string(),
}
}
/// `Variant::to_bool()` for the args toggle.
fn to_bool(v: &NodeValue) -> bool {
match v {
NodeValue::Boolean(b) => *b,
other => other.to_double() != 0.0,
}
}
/// `Variant::to_vec2()` for the inherited position/size inputs.
fn to_vec2(v: &NodeValue) -> [f64; 2] {
match v {
NodeValue::Vec2(a) => *a,
other => [other.to_double(), 0.0],
}
}
/// Largest rasterization dimension accepted from `size_in`. Both the
/// CPU staging buffer and the intermediate GPU textures are `w * h * 4`
/// bytes, so an absurd shape size is clamped rather than allocated.
const MAX_RASTER_SIZE: i32 = 8192;
/// Shader id of the outline dilation pass: a square dilation (the
/// algorithm of [`super::dilate`], with the radius read from
/// [`OUTLINE_WIDTH_INPUT`]).
pub const OUTLINE_DILATE_SHADER_ID: &str = "outline_dilate";
/// Shader id of the outline colorize pass: multiplies the dilated
/// coverage by [`OUTLINE_COLOR_INPUT`].
pub const OUTLINE_COLORIZE_SHADER_ID: &str = "outline_colorize";
/// Shader id of the glow blur pass. The job runs one iteration per axis
/// (horizontal, then vertical).
pub const GLOW_BLUR_SHADER_ID: &str = "glow_blur";
/// Shader id of the glow colorize pass: multiplies the blurred coverage
/// by [`GLOW_COLOR_INPUT`].
pub const GLOW_COLORIZE_SHADER_ID: &str = "glow_colorize";
/// Effect-texture input id shared by the four post-process shaders: the
/// coverage texture produced by the preceding pass.
const POST_TEXTURE_INPUT: &str = "tex_in";
/// Resolution input id. The raster resolution is inserted explicitly so
/// the evaluation pass cannot pre-fill it with the sequence resolution.
const RESOLUTION_INPUT: &str = "resolution_in";
/// Fragment shader of the outline dilation pass: the square
/// `(2r+1)^2` max filter of [`super::dilate`], with the radius taken
/// from `outline_width_in` (rounded and clamped to `0..=7`) instead of
/// the dilate node's own input and the rest copied verbatim.
const OUTLINE_DILATE_FRAG: &str = r#"uniform sampler2D tex_in;
uniform float outline_width_in;
uniform vec2 resolution_in;
in vec2 ove_texcoord;
out vec4 frag_color;
void main() {
int radius = int(clamp(outline_width_in, 0.0, 7.0) + 0.5);
vec2 texel = vec2(1.0) / resolution_in;
vec4 acc = texture(tex_in, ove_texcoord);
for (int dy = -radius; dy <= radius; ++dy) {
for (int dx = -radius; dx <= radius; ++dx) {
vec2 uv = ove_texcoord + vec2(float(dx), float(dy)) * texel;
acc = max(acc, texture(tex_in, uv));
}
}
frag_color = acc;
}
"#;
/// Fragment shader of the outline colorize pass: tint the coverage with
/// [`OUTLINE_COLOR_INPUT`] and emit it premultiplied, so it composites
/// as `color * alpha` over whatever is beneath it.
const OUTLINE_COLORIZE_FRAG: &str = r#"uniform sampler2D tex_in;
uniform vec4 outline_color_in;
in vec2 ove_texcoord;
out vec4 frag_color;
void main() {
vec4 coverage = texture(tex_in, ove_texcoord);
float alpha = coverage.a * outline_color_in.a;
frag_color = vec4(outline_color_in.rgb * alpha, alpha);
}
"#;
/// Fragment shader of the glow blur pass: a box blur over the axis
/// selected by `ove_iteration` (0 = horizontal, 1 = vertical), taps one
/// texel apart and averaged over `2r + 1`. `glow_radius_in` is rounded
/// and clamped to `0..=64`; a sub-pixel radius passes the texture
/// through.
const GLOW_BLUR_FRAG: &str = r#"uniform sampler2D tex_in;
uniform float glow_radius_in;
uniform vec2 resolution_in;
uniform int ove_iteration;
in vec2 ove_texcoord;
out vec4 frag_color;
void main() {
int radius = int(clamp(glow_radius_in, 0.0, 64.0) + 0.5);
if (radius < 1) {
frag_color = texture(tex_in, ove_texcoord);
return;
}
vec4 composite = vec4(0.0);
for (int i = -radius; i <= radius; ++i) {
vec2 uv = ove_texcoord;
if (ove_iteration == 0) {
uv.x += float(i) / resolution_in.x;
} else {
uv.y += float(i) / resolution_in.y;
}
composite += texture(tex_in, uv);
}
frag_color = composite / float(radius * 2 + 1);
}
"#;
/// Fragment shader of the glow colorize pass: tint the blurred coverage
/// with [`GLOW_COLOR_INPUT`] and emit it premultiplied.
const GLOW_COLORIZE_FRAG: &str = r#"uniform sampler2D tex_in;
uniform vec4 glow_color_in;
in vec2 ove_texcoord;
out vec4 frag_color;
void main() {
vec4 coverage = texture(tex_in, ove_texcoord);
float alpha = coverage.a * glow_color_in.a;
frag_color = vec4(glow_color_in.rgb * alpha, alpha);
}
"#;
impl TextGeneratorV3 {
/// Map our alignment to the gizmo's alignment int (C++
/// `get_qt_alignment_from_ours()`): Top -> `TextGizmo::k_align_top`,
/// Middle -> `TextGizmo::k_align_vcenter`, Bottom ->
/// `TextGizmo::k_align_bottom` (0 = top, 1 = bottom, 2 = vcenter in
/// the gizmo's numbering).
#[allow(dead_code)] // C++ parity helpers; covered by the tests.
pub fn get_gizmo_alignment_from_ours(v: VerticalAlignment) -> i32 {
match v {
VerticalAlignment::Top => 0,
VerticalAlignment::Middle => 2,
VerticalAlignment::Bottom => 1,
}
}
/// Map the gizmo's alignment int back to ours (C++
/// `get_our_alignment_from_qts()`); unknown values map to
/// [`VerticalAlignment::Top`].
pub fn get_our_alignment_from_gizmos(v: i32) -> VerticalAlignment {
match v {
1 => VerticalAlignment::Bottom,
2 => VerticalAlignment::Middle,
_ => VerticalAlignment::Top,
}
}
/// The current alignment (C++ `get_vertical_alignment()`): the
/// `valign_in` standard value as a [`VerticalAlignment`].
pub fn vertical_alignment(core: &NodeCore) -> VerticalAlignment {
VerticalAlignment::from_int(
core.standard_value(VERTICAL_ALIGNMENT_INPUT, -1)
.to_double() as i32,
)
}
/// Expand `%N` placeholders with args (C++ `format_string()`):
/// `%%` yields a literal `%`; `%` followed by digits parses an int
/// (out-of-int-range parses fail to 0, making the index -1) and
/// substitutes `args[index - 1]` when in range, otherwise expands
/// to nothing; a lone `%` before a non-digit/non-`%` is copied
/// verbatim.
pub fn format_string(input: &str, args: &[String]) -> String {
let bytes = input.as_bytes();
let mut output = String::new();
let mut i = 0;
while i < bytes.len() {
let c = bytes[i] as char;
if i + 1 < bytes.len() && c == '%' {
let next = bytes[i + 1] as char;
if next == '%' {
// Double percent, append a single percent.
output.push('%');
i += 1;
} else if next.is_ascii_digit() {
// Find the length of the number (QString::toInt()
// semantics: out-of-int-range parses fail and yield 0,
// making the index -1).
let mut num = String::new();
i += 1;
while i < bytes.len() && bytes[i].is_ascii_digit() {
num.push(bytes[i] as char);
i += 1;
}
i -= 1;
let n: i64 = num.parse().unwrap_or(0);
let index = if n > i32::MAX as i64 || n < i32::MIN as i64 {
-1
} else {
(n as i32) - 1
};
if index >= 0 && (index as usize) < args.len() {
output.push_str(&args[index as usize]);
}
} else {
output.push(c);
}
} else {
output.push(c);
}
i += 1;
}
output
}
/// Gizmo activated callback (C++ `gizmo_activated()`): sets
/// `use_args_in` to `false` and `dont_emit_valign_ = true`.
#[allow(dead_code)] // C++ parity callbacks; covered by the tests.
fn gizmo_activated(&mut self, core: &mut NodeCore) {
core.set_standard_value(USE_ARGS_INPUT, -1, NodeValue::Boolean(false));
self.dont_emit_valign = true;
}
/// Gizmo deactivated callback (C++ `gizmo_deactivated()`): sets
/// `use_args_in` to `true` and `dont_emit_valign_ = true`.
#[allow(dead_code)]
fn gizmo_deactivated(&mut self, core: &mut NodeCore) {
core.set_standard_value(USE_ARGS_INPUT, -1, NodeValue::Boolean(true));
self.dont_emit_valign = true;
}
/// Set the vertical alignment through the undo system (C++
/// `set_vertical_alignment_undoable()`, formerly a
/// `NodeParamSetStandardValueCommand` on the undo stack). The undo
/// command stack is not part of this crate, so only the resulting
/// standard-value write is performed (`// CPP-PARITY: textv3.cpp`
/// `set_vertical_alignment_undoable`).
#[allow(dead_code)]
fn set_vertical_alignment_undoable(&mut self, core: &mut NodeCore, a: i32) {
core.set_standard_value(
VERTICAL_ALIGNMENT_INPUT,
-1,
NodeValue::Combo(Self::get_our_alignment_from_gizmos(a) as i64),
);
}
}
impl NodeBehavior for TextGeneratorV3 {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Text"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.text3"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Generator]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Generate rich text."
}
/// Localized input names (C++ `retranslate()`): `text_in` ->
/// "Text", `valign_in` -> "Vertical Alignment" (combo strings
/// Top/Middle/Bottom), `args_in` -> "Arguments"; the base class
/// retranslate covers the inherited shape inputs and `base_in`
/// ("Base"). The redesign inputs are named here too ("Text" for
/// `plain_text_in`, "Font Family", "Font Size", "Outline"/"Outline
/// Color"/"Outline Width", "Glow"/"Glow Color"/"Glow Radius").
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
TEXT_INPUT | PLAIN_TEXT_INPUT => "Text",
FONT_FAMILY_INPUT => "Font Family",
FONT_SIZE_INPUT => "Font Size",
OUTLINE_ENABLED_INPUT => "Outline",
OUTLINE_COLOR_INPUT => "Outline Color",
OUTLINE_WIDTH_INPUT => "Outline Width",
GLOW_ENABLED_INPUT => "Glow",
GLOW_COLOR_INPUT => "Glow Color",
GLOW_RADIUS_INPUT => "Glow Radius",
COLOR_INPUT => "Color",
VERTICAL_ALIGNMENT_INPUT => "Vertical Alignment",
ARGS_INPUT => "Arguments",
crate::nodes::generatorwithmerge::BASE_INPUT => "Base",
_ => crate::nodes::shapenodebase::ShapeNodeBase::input_name(id),
}
}
/// Combo input option labels (C++ `retranslate()` /
/// `set_combo_box_strings`): `valign_in` -> "Top", "Middle",
/// "Bottom". The redesign's `font_family_in` is a str-combo whose
/// option list is injected by the backend layer, so it has no
/// static labels here.
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
match id {
VERTICAL_ALIGNMENT_INPUT => vec!["Top", "Middle", "Bottom"],
_ => Vec::new(),
}
}
/// Shader code request: the merged generate shader is served under
/// the `"mrg"` request (shared with
/// [`crate::nodes::generatorwithmerge`]), the four post-process passes
/// (REDESIGN, no C++ counterpart) under their shader ids. Every other
/// request is unhandled.
fn shader_code(&self, request: &str) -> Option {
match request {
"mrg" => Some(crate::nodes::generatorwithmerge::merge_shader_frag().to_string()),
OUTLINE_DILATE_SHADER_ID => Some(OUTLINE_DILATE_FRAG.to_string()),
OUTLINE_COLORIZE_SHADER_ID => Some(OUTLINE_COLORIZE_FRAG.to_string()),
GLOW_BLUR_SHADER_ID => Some(GLOW_BLUR_FRAG.to_string()),
GLOW_COLORIZE_SHADER_ID => Some(GLOW_COLORIZE_FRAG.to_string()),
_ => None,
}
}
/// Evaluate outputs (C++ `value()`): if `use_args_in` is set and
/// the args array is non-empty, expand `%N` placeholders in the
/// text via [`Self::format_string`]; if the resulting text is
/// non-empty, push a merged texture generate job (params from the
/// incoming base texture when present, else the global video
/// params, forced to `PixelFormat::u8` and the project's default
/// input color space, with the expanded text inserted back into
/// the job); otherwise pass the base input texture through
/// unchanged.
///
/// REDESIGN: the text evaluated comes from [`PLAIN_TEXT_INPUT`] when
/// a text layout backend is installed (the structured path) and from
/// the legacy [`TEXT_INPUT`] HTML otherwise, so a backend-less build
/// keeps the pre-redesign behavior exactly. The C++ builds the layout
/// request here (`Texture::job(text_params, job)` carries the
/// laid-out document); the Rust job has no payload, so the request is
/// built by [`Self::layout_request`] instead and this method only
/// decides which text the job describes.
///
/// REDESIGN (wave 2): with an outline and/or glow pass enabled,
/// [`Self::build_post_job`] rasterizes the evaluated text and the
/// pushed handle carries the post-process chain instead of the plain
/// generate job. Either way the job goes through
/// [`crate::nodes::generatorwithmerge::GeneratorWithMerge::push_mergable_job`]
/// (with a null handle — the renderer-deferred generate job — when no
/// pass is enabled, which is the pre-redesign behavior); the args
/// array resolves to the single row value when present (a per-element
/// array model is deferred), so `%N` expansion is exercised directly
/// via [`Self::format_string`] (`// CPP-PARITY: textv3.cpp` `value()`).
fn value(
&self,
core: &NodeCore,
inputs: &NodeValueRow,
time: Rational,
table: &mut NodeValueTable,
) {
let mut text = Self::job_text(Self::plain_text_path(), core, inputs, time);
let use_args_val = inputs
.get(USE_ARGS_INPUT)
.cloned()
.unwrap_or_else(|| core.value_at_time(USE_ARGS_INPUT, -1, time));
if to_bool(&use_args_val) {
let args: Vec = match inputs.get(ARGS_INPUT) {
Some(NodeValue::Text(s)) => vec![s.clone()],
_ => Vec::new(),
};
if !args.is_empty() {
text = Self::format_string(&text, &args);
}
}
if !text.is_empty() {
// C++ `push_mergable_job(value, Texture::job(text_params, job),
// table)` — merged over base_in when connected, else pushed
// directly. An enabled outline/glow pass boxes the post-process
// chain (REDESIGN wave 2); with both passes off the plain
// raster is the output — the pre-backend deferred null job only
// applies when no render backend is installed.
let job = match self.build_post_job(core, inputs, &text, time) {
Some(job) => job,
None => {
let size = Self::raster_size(core, inputs, time);
let align = Self::alignment_arg(core, inputs);
let font_color = Self::color_arg(core, inputs, COLOR_INPUT, time);
match Self::rasterize_text(inputs, &text, size, align, font_color) {
// The addref runs while the value owns its handle
// reference (NodeValue::drop releases it) — taking
// the bare handle out first would dangle it.
Some(NodeValue::Texture(handle)) => unsafe { handle.addref() },
_ => crate::handle::CHandle::null(),
}
}
};
crate::nodes::generatorwithmerge::GeneratorWithMerge::push_mergable_job(
inputs, job, table,
);
} else if let Some(base @ NodeValue::Texture(_)) =
inputs.get(crate::nodes::generatorwithmerge::BASE_INPUT)
{
table.push(base.value_type(), base.clone(), None);
}
}
/// Direct frame generation (C++ `generate_frame()`): clears the
/// RGBA8888-premultiplied frame to transparent, then (only when a
/// measure backend is installed) lays out the text as Olive HTML at
/// 96 DPI (3780 dots/meter) wrapped to the shape size X, computes
/// the base offset from the shape position re-centered into frame
/// space, applies the vertical alignment to the draw offset (top:
/// none; middle: `size.y/2 - doc.height/2`; bottom: `size.y -
/// doc.height`), clips to the shape rect at the base offset, and
/// renders over the buffer via the render backend. With no measure
/// backend installed, warns once and leaves the cleared frame
/// untouched.
///
/// The Rust frame is an opaque [`crate::handle::CHandle`]
/// whose pixels cannot be read or written from this crate, so the
/// body is a documented no-op; the layout/measure/offset control flow
/// is ported in [`Self::layout_request`], [`Self::base_offset`] and
/// [`Self::draw_offset`], and exercised by the tests.
fn generate_frame(
&self,
core: &NodeCore,
frame: &mut crate::handle::CHandle,
time: Rational,
) {
let _ = (core, frame, time);
}
/// Gizmo position update (C++ `update_gizmo_positions()`): after
/// the base update, sets the text gizmo rect to the bounding rect
/// of the polygon gizmo's polygon (empty polygon -> zero rect) and
/// feeds it the current `text_in` HTML.
///
/// The polygon/text gizmos live in the GUI layer with no Rust model
/// in this crate, so this is a documented no-op
/// (`// CPP-PARITY: textv3.cpp` `update_gizmo_positions`).
fn gizmo_update(&self, core: &NodeCore, row: &NodeValueRow) {
let _ = (core, row);
}
/// Input value changed (C++ `InputValueChangedEvent()`): when
/// `valign_in` changes and `dont_emit_valign_` is not set, forwards
/// the new alignment to the text gizmo; then defers to the base
/// implementation.
///
/// The text gizmo has no Rust model in this crate, so only the
/// flag check is represented (`// CPP-PARITY: textv3.cpp`
/// `InputValueChangedEvent`). REDESIGN: a change of the legacy
/// [`TEXT_INPUT`] HTML also runs the one-shot HTML-to-plain-text
/// migration ([`migrate_legacy_html`] — one of its three call
/// sites, see the function).
fn input_value_changed(&mut self, core: &mut NodeCore, input: &str, element: i32) {
let _ = element;
if input == VERTICAL_ALIGNMENT_INPUT && !self.dont_emit_valign {
// The C++ forwards the new alignment to the text gizmo here.
}
if input == TEXT_INPUT {
migrate_legacy_html(core);
}
}
/// Post-load fixups (C++ `PostLoadEvent`): runs the REDESIGN
/// HTML-to-plain-text migration ([`migrate_legacy_html`]) for load
/// pipelines that call this hook after the inputs are applied.
fn post_load(&mut self, core: &mut NodeCore) {
migrate_legacy_html(core);
}
/// Custom load (C++ `load_custom()`): consume the `` segment
/// exactly like the default implementation, then run the REDESIGN
/// migration. The node-body parser writes the `` values before
/// the trailing `` element, so this is the hook that fires
/// with the legacy [`TEXT_INPUT`] value already loaded.
fn load_custom(
&mut self,
core: &mut NodeCore,
reader: &mut dyn crate::serializer::XmlRead,
) -> bool {
reader.skip_current_element();
migrate_legacy_html(core);
true
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option> {
Some(Box::new(TextGeneratorV3 {
dont_emit_valign: self.dont_emit_valign,
}))
}
/// Downcast to [`Self`] (gizmo-state access).
fn as_any(&self) -> Option<&dyn std::any::Any> {
Some(self)
}
/// Mutable downcast (see [`NodeBehavior::as_any`]).
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
}
impl TextGeneratorV3 {
/// Whether the structured plain-text path is active (REDESIGN):
/// `true` when a text layout backend is installed. Without a backend
/// the node keeps the pre-redesign behavior (the legacy
/// [`TEXT_INPUT`] HTML is carried).
pub fn plain_text_path() -> bool {
super::textbackend::text_measure_backend().is_some()
}
/// The text [`Self::value`] evaluates (REDESIGN split of the C++
/// `value()` text extraction): [`PLAIN_TEXT_INPUT`] on the
/// `plain_text == true` path, the legacy [`TEXT_INPUT`] HTML
/// otherwise. Row values win over the core's value at `time`, like
/// the C++ input evaluation.
fn job_text(
plain_text: bool,
core: &NodeCore,
inputs: &NodeValueRow,
time: Rational,
) -> String {
let id = if plain_text {
PLAIN_TEXT_INPUT
} else {
TEXT_INPUT
};
let val = inputs
.get(id)
.cloned()
.unwrap_or_else(|| core.value_at_time(id, -1, time));
to_text(&val)
}
/// Build the C++ `TextLayoutRequest` (textv3.cpp `generate_frame()`)
/// for the active text path: with a backend installed, the structured
/// request — [`PLAIN_TEXT_INPUT`] as [`TextLayoutMode::PlainText`]
/// with `font_family_in`/`font_size_in` — else the pre-redesign
/// request, Olive-HTML text from [`TEXT_INPUT`] at 96 DPI (3780
/// dots/meter) with the font taken from the markup. Both wrap to the
/// shape size X; the backend defaults are used when font family/size
/// are empty/zero.
#[allow(dead_code)]
pub fn layout_request(row: &NodeValueRow) -> TextLayoutRequest {
Self::layout_request_path(Self::plain_text_path(), row)
}
/// [`Self::layout_request`] with the path chosen explicitly: the
/// backend state is a process-global, so the tests drive both paths
/// through this parameter instead of installing hooks.
fn layout_request_path(plain_text: bool, row: &NodeValueRow) -> TextLayoutRequest {
let size = row
.get(crate::nodes::shapenodebase::SIZE_INPUT)
.map(to_vec2)
.unwrap_or([0.0, 0.0]);
if plain_text {
TextLayoutRequest {
text: row
.get(PLAIN_TEXT_INPUT)
.map(to_text)
.unwrap_or_default(),
mode: TextLayoutMode::PlainText,
font_family: row
.get(FONT_FAMILY_INPUT)
.map(to_text)
.unwrap_or_default(),
font_size_pt: row
.get(FONT_SIZE_INPUT)
.map(|v| v.to_double())
.unwrap_or(0.0),
dots_per_meter: 3780,
wrap_width: size[0],
center_horizontally: false,
}
} else {
TextLayoutRequest {
text: row.get(TEXT_INPUT).map(to_text).unwrap_or_default(),
mode: TextLayoutMode::OliveHtml,
font_family: String::new(),
font_size_pt: 0.0,
dots_per_meter: 3780,
wrap_width: size[0],
center_horizontally: false,
}
}
}
/// The C++ base offset (textv3.cpp `generate_frame()`): the shape
/// position re-centered into frame space — `pos - size/2 + frame/2`
/// (the frame halves are integer division in C++).
#[allow(dead_code)] // C++ parity helpers; the tests drive them.
pub fn base_offset(
pos: [f64; 2],
size: [f64; 2],
frame_width: i32,
frame_height: i32,
) -> (f64, f64) {
(
pos[0] - size[0] / 2.0 + (frame_width / 2) as f64,
pos[1] - size[1] / 2.0 + (frame_height / 2) as f64,
)
}
/// The C++ draw offset (textv3.cpp `generate_frame()`): the base
/// offset plus the vertical-alignment delta — top: none; middle:
/// `size.y/2 - doc.height/2`; bottom: `size.y - doc.height` (all
/// double math, unlike the integer halving in v2).
pub fn draw_offset(
align: VerticalAlignment,
base: (f64, f64),
size: [f64; 2],
doc_height: f64,
) -> (f64, f64) {
let (dx, mut dy) = base;
match align {
VerticalAlignment::Top => {}
VerticalAlignment::Middle => dy += size[1] / 2.0 - doc_height / 2.0,
VerticalAlignment::Bottom => dy += size[1] - doc_height,
}
(dx, dy)
}
/// The C++ `TextRenderTransform` (textv3.cpp `generate_frame()`):
/// scale, the draw offset, and the clip rect at the base offset
/// covering the shape size (set before the vertical-alignment
/// translate in the C++).
pub fn render_transform(
scale: f64,
draw: (f64, f64),
base: (f64, f64),
size: [f64; 2],
) -> TextRenderTransform {
TextRenderTransform {
scale,
draw_offset_x: draw.0,
draw_offset_y: draw.1,
clip_enabled: true,
clip_offset_x: base.0,
clip_offset_y: base.1,
clip_width: size[0],
clip_height: size[1],
}
}
/// The layout/measure control flow of the C++ `generate_frame()` with
/// the backend hooks: build the request and measure via the installed
/// measure backend (zero size when none is installed — the documented
/// no-backend fallback; the frame is left cleared).
///
/// The render step needs the frame's pixel buffer, which the Rust
/// frame handle does not expose; it is not representable here
/// (`// CPP-PARITY: textv3.cpp` `generate_frame`).
#[allow(dead_code)]
pub fn measure_and_layout(row: &NodeValueRow) -> (TextLayoutRequest, TextLayoutSize) {
let req = Self::layout_request(row);
let doc = match super::textbackend::text_measure_backend() {
Some(measure) => measure(&req),
None => TextLayoutSize::default(),
};
(req, doc)
}
/// Read an input: the row value when present, else the core's value at
/// `time` (the row-first lookup [`Self::job_text`] uses).
fn input_value(core: &NodeCore, inputs: &NodeValueRow, id: &str, time: Rational) -> NodeValue {
inputs
.get(id)
.cloned()
.unwrap_or_else(|| core.value_at_time(id, -1, time))
}
/// Read a float input (REDESIGN post-process inputs).
fn float_arg(core: &NodeCore, inputs: &NodeValueRow, id: &str, time: Rational) -> f64 {
Self::input_value(core, inputs, id, time).to_double()
}
/// Read a boolean input (REDESIGN post-process inputs).
fn bool_arg(core: &NodeCore, inputs: &NodeValueRow, id: &str, time: Rational) -> bool {
let val = Self::input_value(core, inputs, id, time);
to_bool(&val)
}
/// Read a color input; a mis-typed value falls back to opaque black
/// (the same fallback the C++ `Variant::to_color()` callers get for a
/// non-color).
fn color_arg(core: &NodeCore, inputs: &NodeValueRow, id: &str, time: Rational) -> [f64; 4] {
match Self::input_value(core, inputs, id, time) {
NodeValue::Color(c) => c,
_ => [0.0, 0.0, 0.0, 1.0],
}
}
/// The vertical alignment of this evaluation: the row's `valign_in`
/// when present, else the standard value (the lookup
/// [`Self::vertical_alignment`] documents).
fn alignment_arg(core: &NodeCore, inputs: &NodeValueRow) -> VerticalAlignment {
match inputs.get(VERTICAL_ALIGNMENT_INPUT) {
Some(v) => VerticalAlignment::from_int(v.to_double() as i32),
None => Self::vertical_alignment(core),
}
}
/// Clamp one raster dimension into `1..=MAX_RASTER_SIZE`; a
/// non-finite size falls back to a single pixel.
fn clamp_raster(v: f64) -> i32 {
if !v.is_finite() {
return 1;
}
(v.round() as i32).clamp(1, MAX_RASTER_SIZE)
}
/// The rasterization size of this evaluation: the shape size
/// (`size_in`), rounded and clamped per dimension.
fn raster_size(core: &NodeCore, inputs: &NodeValueRow, time: Rational) -> (i32, i32) {
let size = to_vec2(&Self::input_value(
core,
inputs,
crate::nodes::shapenodebase::SIZE_INPUT,
time,
));
(Self::clamp_raster(size[0]), Self::clamp_raster(size[1]))
}
/// Box one post-process shader job (REDESIGN, no C++ counterpart):
/// this node's type id (the chain is all ours), an invalid node id
/// (the jobs are synthetic — no graph node evaluates them) and the
/// shader id selecting the pass in [`Self::shader_code`].
fn shader_job(
time: Rational,
type_id: &str,
shader_id: &str,
effect_input: &str,
iterations: i32,
params: NodeValueRow,
) -> crate::handle::CHandle {
crate::handle::make_owned(Job::ShaderJob(ShaderJobPayload {
node_id: crate::id::NodeId::INVALID,
time,
iterations,
type_id: type_id.to_string(),
shader_id: shader_id.to_string(),
effect_input: effect_input.to_string(),
params,
iterative_input: String::new(),
}))
}
/// Box a `"mrg"` job drawing `blend` (the top layer) over `base` (the
/// backdrop): the merge node's premultiplied alpha-over,
/// `base = base * (1 - blend.a) + blend`.
fn merge_job(
time: Rational,
type_id: &str,
base: &NodeValue,
blend: &NodeValue,
) -> crate::handle::CHandle {
let mut params = NodeValueRow::new();
params.insert(crate::nodes::merge::BASE_INPUT.to_string(), base.clone());
params.insert(crate::nodes::merge::BLEND_INPUT.to_string(), blend.clone());
Self::shader_job(
time,
type_id,
"mrg",
crate::nodes::merge::BASE_INPUT,
1,
params,
)
}
/// Rasterize the evaluated `text` into an RGBA premultiplied F32
/// coverage texture (REDESIGN, no C++ counterpart — the C++ renders
/// straight into the output frame instead): layout the plain-text
/// request with `text` substituted for [`PLAIN_TEXT_INPUT`], measure
/// it, render into a `size`-sized staging buffer with the crate's
/// draw/clip transform, then widen the 8-bit coverage to float.
///
/// `None` without a render backend (the documented no-backend
/// fallback), for an empty raster, or when the staging frame cannot be
/// allocated. The white raster is tinted by `color` (the font color,
/// [`COLOR_INPUT`]) while widening to float.
fn rasterize_text(
row: &NodeValueRow,
text: &str,
size: (i32, i32),
align: VerticalAlignment,
color: [f64; 4],
) -> Option {
let render = super::textbackend::text_render_backend()?;
let (width, height) = size;
if width <= 0 || height <= 0 {
return None;
}
let mut req_row = row.clone();
req_row.insert(
PLAIN_TEXT_INPUT.to_string(),
NodeValue::Text(text.to_string()),
);
let req = Self::layout_request_path(true, &req_row);
let doc = match super::textbackend::text_measure_backend() {
Some(measure) => measure(&req),
None => TextLayoutSize::default(),
};
// The backend writes 8-bit premultiplied RGBA over the existing
// (cleared) rows; the shape-local offsets keep the text rect at
// the raster origin, with the vertical alignment applied.
let mut rgba = vec![0u8; (width as usize) * (height as usize) * 4];
{
let target = super::textbackend::TextRenderTarget {
data: &mut rgba,
width,
height,
linesize_bytes: width * 4,
channel_count: 4,
};
let draw =
Self::draw_offset(align, (0.0, 0.0), [width as f64, height as f64], doc.height);
let transform =
Self::render_transform(1.0, draw, (0.0, 0.0), [width as f64, height as f64]);
render(&req, &transform, target);
}
let mut frame = Frame::new();
frame.set_video_params(VideoParamsPod {
width,
height,
..Default::default()
});
if !frame.allocate() {
return None;
}
for (pixel, coverage) in frame.data.chunks_exact_mut(16).zip(rgba.chunks_exact(4)) {
// The backend rasterizes in opaque-premultiplied white; tint by
// [`COLOR_INPUT`] per channel (the alpha scales too — a
// half-transparent font color stays premultiplied).
for (c, (channel, byte)) in pixel.chunks_exact_mut(4).zip(coverage).enumerate() {
let v = f32::from(*byte) / 255.0 * color[c] as f32;
channel.copy_from_slice(&v.to_le_bytes());
}
}
Some(NodeValue::Texture(crate::handle::make_owned(
Texture::wrap_frame(frame),
)))
}
/// Build the outline/glow post-process chain (REDESIGN, no C++
/// counterpart) for the evaluated `text`: rasterize the coverage,
/// dilate and colorize it into a stroke when the outline is enabled,
/// blur and colorize it into a glow when the glow is enabled, and
/// merge the results **beneath** the text (the text stays on top).
///
/// With both enabled the outline runs first and the glow samples the
/// stroke; with both disabled — or without a render backend — `None`,
/// so the caller keeps the pre-redesign deferred null job.
fn build_post_job(
&self,
core: &NodeCore,
inputs: &NodeValueRow,
text: &str,
time: Rational,
) -> Option {
let outline = Self::bool_arg(core, inputs, OUTLINE_ENABLED_INPUT, time);
let glow = Self::bool_arg(core, inputs, GLOW_ENABLED_INPUT, time);
if !outline && !glow {
return None;
}
let type_id = self.type_id();
let size = Self::raster_size(core, inputs, time);
let align = Self::alignment_arg(core, inputs);
let font_color = Self::color_arg(core, inputs, COLOR_INPUT, time);
let text_tex = Self::rasterize_text(inputs, text, size, align, font_color)?;
let resolution = NodeValue::Vec2([size.0 as f64, size.1 as f64]);
let mut result = text_tex.clone();
let mut stroke: Option = None;
if outline {
let mut params = NodeValueRow::new();
params.insert(POST_TEXTURE_INPUT.to_string(), text_tex.clone());
params.insert(
OUTLINE_WIDTH_INPUT.to_string(),
NodeValue::Float(Self::float_arg(core, inputs, OUTLINE_WIDTH_INPUT, time)),
);
params.insert(RESOLUTION_INPUT.to_string(), resolution.clone());
let dilated = NodeValue::Texture(Self::shader_job(
time,
type_id,
OUTLINE_DILATE_SHADER_ID,
POST_TEXTURE_INPUT,
1,
params,
));
let mut params = NodeValueRow::new();
params.insert(POST_TEXTURE_INPUT.to_string(), dilated);
params.insert(
OUTLINE_COLOR_INPUT.to_string(),
NodeValue::Color(Self::color_arg(core, inputs, OUTLINE_COLOR_INPUT, time)),
);
params.insert(RESOLUTION_INPUT.to_string(), resolution.clone());
let colorized = NodeValue::Texture(Self::shader_job(
time,
type_id,
OUTLINE_COLORIZE_SHADER_ID,
POST_TEXTURE_INPUT,
1,
params,
));
// The stroke is the widened, colorized coverage drawn over the
// text itself, so the glyphs stay on top of their outline.
let stroke_tex =
NodeValue::Texture(Self::merge_job(time, type_id, &colorized, &text_tex));
stroke = Some(stroke_tex.clone());
result = stroke_tex;
}
if glow {
let source = stroke.clone().unwrap_or_else(|| text_tex.clone());
let mut params = NodeValueRow::new();
params.insert(POST_TEXTURE_INPUT.to_string(), source);
params.insert(
GLOW_RADIUS_INPUT.to_string(),
NodeValue::Float(Self::float_arg(core, inputs, GLOW_RADIUS_INPUT, time)),
);
params.insert(RESOLUTION_INPUT.to_string(), resolution.clone());
// Two iterations, one per axis (the shader picks the axis).
let blurred = NodeValue::Texture(Self::shader_job(
time,
type_id,
GLOW_BLUR_SHADER_ID,
POST_TEXTURE_INPUT,
2,
params,
));
let mut params = NodeValueRow::new();
params.insert(POST_TEXTURE_INPUT.to_string(), blurred);
params.insert(
GLOW_COLOR_INPUT.to_string(),
NodeValue::Color(Self::color_arg(core, inputs, GLOW_COLOR_INPUT, time)),
);
params.insert(RESOLUTION_INPUT.to_string(), resolution.clone());
let glow_tex = NodeValue::Texture(Self::shader_job(
time,
type_id,
GLOW_COLORIZE_SHADER_ID,
POST_TEXTURE_INPUT,
1,
params,
));
// The glow is drawn over the stroke (or the bare text when the
// outline is off), which is drawn over the text.
let blend = stroke.clone().unwrap_or_else(|| text_tex.clone());
result = NodeValue::Texture(Self::merge_job(time, type_id, &glow_tex, &blend));
}
let NodeValue::Texture(handle) = &result else {
return None;
};
Some(unsafe { handle.addref() })
}
}
/// Strip an HTML fragment to plain text (REDESIGN helper, no C++
/// counterpart): every `<...>` tag is dropped, then the entities
/// `&`, `<`, `>`, `"`, `'` and ` ` are decoded
/// (the latter to a plain space — a non-breaking space is not
/// representable in the plain-text input, a documented simplification).
/// Unknown entities and a bare `&` are copied verbatim; an unterminated
/// `<` swallows the rest of the input.
///
/// This is a simple stripper, not a conforming HTML parser: tags are
/// dropped first and entities decoded afterwards in a single pass (so
/// `<p>` stays the literal text `
`), and whitespace is neither
/// collapsed nor trimmed (so `
a
b
` becomes `ab`). It only
/// exists to migrate the legacy [`TEXT_INPUT`] payload into
/// [`PLAIN_TEXT_INPUT`].
pub fn strip_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] = [
("&", "&"),
("<", "<"),
(">", ">"),
(""", "\""),
("'", "'"),
(" ", " "),
];
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.
i += 1;
while i < chars.len() && chars[i] != '>' {
i += 1;
}
i += 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;
}
}
}
out
}
/// One-shot migration of a pre-redesign project's legacy [`TEXT_INPUT`]
/// HTML into [`PLAIN_TEXT_INPUT`] (REDESIGN helper, no C++
/// counterpart): when the plain text is still untouched (empty or the
/// [`DEFAULT_PLAIN_TEXT`] default) and the legacy input holds a
/// non-empty, non-default HTML value, the stripped plain text is written
/// to `plain_text_in` and `true` is returned. The legacy value is never
/// modified, so an old project can still be saved in its original form;
/// after a successful migration the plain text is no longer the default
/// and further calls are no-ops (idempotent).
///
/// A project created after the redesign serializes `plain_text_in` at
/// the same default, indistinguishable through the standard values from
/// a legacy node with an untouched default HTML payload; that case is
/// left alone (the default HTML is not migrated) rather than replacing
/// the redesign default with the legacy "Sample Text".
///
/// Call sites: [`NodeBehavior::load_custom`] (fires in the node-body
/// parser after the `` elements — the hook the real load path
/// reaches), [`NodeBehavior::post_load`] (for load pipelines that call
/// it after the inputs are applied) and [`NodeBehavior::input_value_changed`]
/// for the legacy input. The migration only takes effect in the facade
/// once a loader calls one of them.
pub fn migrate_legacy_html(core: &mut NodeCore) -> bool {
if core.get_input(PLAIN_TEXT_INPUT).is_none() {
return false;
}
let plain_untouched = matches!(
&core.standard_value(PLAIN_TEXT_INPUT, -1),
NodeValue::Text(s) if s.is_empty() || s == DEFAULT_PLAIN_TEXT
);
if !plain_untouched {
return false;
}
let plain = match &core.standard_value(TEXT_INPUT, -1) {
NodeValue::Text(t) if !t.is_empty() && t != LEGACY_DEFAULT_TEXT_HTML => {
strip_html_to_plain(t)
}
_ => return false,
};
if plain.is_empty() {
return false;
}
core.set_standard_value(PLAIN_TEXT_INPUT, -1, NodeValue::Text(plain));
true
}
/// Constructor (C++ `TextGeneratorV3::TextGeneratorV3()`): builds the
/// shape base without its own gizmo behavior (`ShapeNodeBase(false)`),
/// adds `text_in` (hidden, REDESIGN), the structured redesign inputs
/// (`plain_text_in`, `font_family_in`, `font_size_in`, `outline_*`,
/// `glow_*`), `valign_in`, `use_args_in` and `args_in` with the
/// defaults, flags and properties documented on the constants, sets the
/// inherited `size_in` standard value to `(400, 300)`, creates the
/// `TextGizmo` bound to `text_in`, and initializes
/// `dont_emit_valign_ = false`.
///
/// The `TextGizmo` is a GUI-layer gizmo with no Rust model (see the
/// struct doc); the inherited inputs (`base_in` from the merge base,
/// `pos_in`/`size_in` from the shape base without its color input) are
/// wired here, mirroring the C++ constructor chain `Node ->
/// GeneratorWithMerge -> ShapeNodeBase(false) -> TextGeneratorV3`.
pub fn create() -> (NodeCore, Box) {
let mut core = NodeCore::new();
// GeneratorWithMerge base: base_in texture effect input.
let mut base = crate::input::Input::new(
crate::nodes::generatorwithmerge::BASE_INPUT,
crate::value::ValueType::Texture,
NodeValue::None,
);
base.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(base);
core.effect_input = crate::nodes::generatorwithmerge::BASE_INPUT.to_string();
core.flags |= crate::node::flags::VIDEO_EFFECT;
// REDESIGN (W6): text is a footage entry (the project panel's "add text
// footage" button) and a timeline clip, no longer an effect the user
// adds to a chain. The flag hides it from the effect library / add
// menus only — `VIDEO_EFFECT` stays so text3 nodes already in a project
// keep evaluating, and the factory keeps registering the type (the
// footage path and old project files create it directly).
core.flags |= crate::node::flags::DONT_SHOW_IN_CREATE_MENU;
// ShapeNodeBase(false): pos/size, no color input.
core.add_input(crate::input::Input::new(
crate::nodes::shapenodebase::POSITION_INPUT,
crate::value::ValueType::Vec2,
NodeValue::Vec2([0.0, 0.0]),
));
let mut size = crate::input::Input::new(
crate::nodes::shapenodebase::SIZE_INPUT,
crate::value::ValueType::Vec2,
NodeValue::Vec2([100.0, 100.0]),
);
size.properties = vec![("min".to_string(), NodeValue::Vec2([0.0, 0.0]))];
core.add_input(size);
// Own inputs.
// REDESIGN: the structured user-facing inputs. `plain_text_in` is the
// text laid out when a backend is installed; the legacy `text_in`
// stays as the hidden compatibility carrier (see the constants).
core.add_input(crate::input::Input::new(
PLAIN_TEXT_INPUT,
crate::value::ValueType::Text,
NodeValue::Text(DEFAULT_PLAIN_TEXT.to_string()),
));
let mut text = crate::input::Input::new(
TEXT_INPUT,
crate::value::ValueType::Text,
NodeValue::Text(LEGACY_DEFAULT_TEXT_HTML.to_string()),
);
text.flags |= crate::input::flags::HIDDEN;
text.properties = vec![("vieweronly".to_string(), NodeValue::Boolean(true))];
core.add_input(text);
core.add_input(crate::input::Input::new(
FONT_FAMILY_INPUT,
crate::value::ValueType::StrCombo,
NodeValue::StrCombo(String::new()),
));
let mut font_size = crate::input::Input::new(
FONT_SIZE_INPUT,
crate::value::ValueType::Float,
NodeValue::Float(72.0),
);
font_size.properties = vec![("min".to_string(), NodeValue::Float(1.0))];
core.add_input(font_size);
core.add_input(crate::input::Input::new(
OUTLINE_ENABLED_INPUT,
crate::value::ValueType::Boolean,
NodeValue::Boolean(false),
));
core.add_input(crate::input::Input::new(
OUTLINE_COLOR_INPUT,
crate::value::ValueType::Color,
NodeValue::Color([0.0, 0.0, 0.0, 1.0]),
));
let mut outline_width = crate::input::Input::new(
OUTLINE_WIDTH_INPUT,
crate::value::ValueType::Float,
NodeValue::Float(2.0),
);
outline_width.properties = vec![("min".to_string(), NodeValue::Float(0.0))];
core.add_input(outline_width);
core.add_input(crate::input::Input::new(
GLOW_ENABLED_INPUT,
crate::value::ValueType::Boolean,
NodeValue::Boolean(false),
));
core.add_input(crate::input::Input::new(
GLOW_COLOR_INPUT,
crate::value::ValueType::Color,
NodeValue::Color([1.0, 1.0, 0.0, 1.0]),
));
let mut glow_radius = crate::input::Input::new(
GLOW_RADIUS_INPUT,
crate::value::ValueType::Float,
NodeValue::Float(8.0),
);
glow_radius.properties = vec![("min".to_string(), NodeValue::Float(0.0))];
core.add_input(glow_radius);
core.add_input(crate::input::Input::new(
COLOR_INPUT,
crate::value::ValueType::Color,
NodeValue::Color([1.0, 1.0, 1.0, 1.0]),
));
// Hidden alignment / args inputs, unchanged by the redesign.
let mut valign = crate::input::Input::new(
VERTICAL_ALIGNMENT_INPUT,
crate::value::ValueType::Combo,
NodeValue::Combo(0),
);
valign.flags |= STATIC_FLAGS | crate::input::flags::HIDDEN;
core.add_input(valign);
let mut use_args = crate::input::Input::new(
USE_ARGS_INPUT,
crate::value::ValueType::Boolean,
NodeValue::Boolean(true),
);
use_args.flags |= STATIC_FLAGS | crate::input::flags::HIDDEN;
core.add_input(use_args);
let mut args = crate::input::Input::new(
ARGS_INPUT,
crate::value::ValueType::Text,
NodeValue::Text(String::new()),
);
args.flags |= crate::input::flags::ARRAY;
args.properties = vec![("arraystart".to_string(), NodeValue::Int(1))];
core.add_input(args);
// C++ set_standard_value override.
core.set_standard_value(
crate::nodes::shapenodebase::SIZE_INPUT,
-1,
NodeValue::Vec2([400.0, 300.0]),
);
(
core,
Box::new(TextGeneratorV3 {
dont_emit_valign: false,
}),
)
}
/// Register this node type (C++ `k_text_generator_v3` in
/// `factory.cpp::create_from_factory_index`).
pub fn register(meta: &mut Vec) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.text3",
name: "Text",
categories: &[Category::Generator],
create,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::handle::CHandle;
use crate::node::NodeBehavior;
use crate::nodes::textbackend::TextRenderTarget;
use crate::value::{NodeValueTable, ValueType};
use oak_core::Rational;
#[test]
fn input_names() {
let n = TextGeneratorV3 {
dont_emit_valign: false,
};
assert_eq!(n.input_name(TEXT_INPUT), "Text");
// REDESIGN: the plain-text input shares the "Text" display name.
assert_eq!(n.input_name(PLAIN_TEXT_INPUT), "Text");
assert_eq!(n.input_name(FONT_FAMILY_INPUT), "Font Family");
assert_eq!(n.input_name(FONT_SIZE_INPUT), "Font Size");
assert_eq!(n.input_name(OUTLINE_ENABLED_INPUT), "Outline");
assert_eq!(n.input_name(OUTLINE_COLOR_INPUT), "Outline Color");
assert_eq!(n.input_name(OUTLINE_WIDTH_INPUT), "Outline Width");
assert_eq!(n.input_name(GLOW_ENABLED_INPUT), "Glow");
assert_eq!(n.input_name(GLOW_COLOR_INPUT), "Glow Color");
assert_eq!(n.input_name(GLOW_RADIUS_INPUT), "Glow Radius");
assert_eq!(n.input_name(VERTICAL_ALIGNMENT_INPUT), "Vertical Alignment");
assert_eq!(n.input_name(ARGS_INPUT), "Arguments");
assert_eq!(
n.input_name(crate::nodes::generatorwithmerge::BASE_INPUT),
"Base"
);
assert_eq!(
n.input_name(crate::nodes::shapenodebase::POSITION_INPUT),
"Position"
);
assert_eq!(
n.input_name(crate::nodes::shapenodebase::SIZE_INPUT),
"Size"
);
// The hidden use_args_in input has no display name override.
assert_eq!(n.input_name(USE_ARGS_INPUT), USE_ARGS_INPUT);
}
#[test]
fn create_wires_inherited_and_own_inputs() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.text3");
assert_eq!(
core.get_input(TEXT_INPUT).unwrap().value_type,
ValueType::Text
);
assert!(core
.get_input(TEXT_INPUT)
.unwrap()
.properties
.iter()
.any(|(k, v)| k == "vieweronly" && v == &NodeValue::Boolean(true)));
let valign = core.get_input(VERTICAL_ALIGNMENT_INPUT).unwrap();
assert_ne!(valign.flags & crate::input::flags::HIDDEN, 0);
assert_ne!(valign.flags & crate::input::flags::NOT_CONNECTABLE, 0);
assert_ne!(valign.flags & crate::input::flags::NOT_KEYFRAMABLE, 0);
let use_args = core.get_input(USE_ARGS_INPUT).unwrap();
assert_eq!(use_args.default, NodeValue::Boolean(true));
let args = core.get_input(ARGS_INPUT).unwrap();
assert_ne!(args.flags & crate::input::flags::ARRAY, 0);
assert!(args
.properties
.iter()
.any(|(k, v)| k == "arraystart" && v == &NodeValue::Int(1)));
// The base has no color input of its own (ShapeNodeBase(false));
// the font color input (REDESIGN wave 3) takes that exact slot.
assert_eq!(
core.get_input(crate::nodes::shapenodebase::COLOR_INPUT)
.unwrap()
.default,
NodeValue::Color([1.0, 1.0, 1.0, 1.0])
);
assert_eq!(
core.standard_value(crate::nodes::shapenodebase::SIZE_INPUT, -1),
NodeValue::Vec2([400.0, 300.0])
);
assert_eq!(
core.effect_input,
crate::nodes::generatorwithmerge::BASE_INPUT
);
// REDESIGN: v3 left the create menu (text is a footage/clip now), so
// this test flipped from the pre-redesign expectation (`== 0`).
assert_ne!(core.flags & crate::node::flags::DONT_SHOW_IN_CREATE_MENU, 0);
// It stays a video effect: legacy chains must keep evaluating.
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
}
/// The W6 contract: hidden from the add menus, still a working effect
/// node for the chains (and the footage path) that create it directly.
#[test]
fn hidden_from_create_menu_but_still_a_video_effect() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.text3");
assert_ne!(core.flags & crate::node::flags::DONT_SHOW_IN_CREATE_MENU, 0);
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
}
#[test]
fn alignment_round_trip() {
for v in [
VerticalAlignment::Top,
VerticalAlignment::Middle,
VerticalAlignment::Bottom,
] {
let gizmo = TextGeneratorV3::get_gizmo_alignment_from_ours(v);
assert_eq!(TextGeneratorV3::get_our_alignment_from_gizmos(gizmo), v);
}
assert_eq!(
TextGeneratorV3::get_gizmo_alignment_from_ours(VerticalAlignment::Top),
0
);
assert_eq!(
TextGeneratorV3::get_gizmo_alignment_from_ours(VerticalAlignment::Middle),
2
);
assert_eq!(
TextGeneratorV3::get_gizmo_alignment_from_ours(VerticalAlignment::Bottom),
1
);
// Unknown gizmo values map to Top.
assert_eq!(
TextGeneratorV3::get_our_alignment_from_gizmos(99),
VerticalAlignment::Top
);
}
#[test]
fn format_string_expands_args() {
let args = vec!["foo".to_string(), "bar".to_string()];
assert_eq!(
TextGeneratorV3::format_string("hello %1", &args),
"hello foo"
);
assert_eq!(TextGeneratorV3::format_string("%2 %1", &args), "bar foo");
// Out of range expands to nothing.
assert_eq!(TextGeneratorV3::format_string("[%3]", &args), "[]");
assert_eq!(TextGeneratorV3::format_string("[%0]", &args), "[]");
}
#[test]
fn format_string_percent_escapes() {
let args = vec!["foo".to_string()];
assert_eq!(TextGeneratorV3::format_string("100%%", &args), "100%");
assert_eq!(TextGeneratorV3::format_string("%%1", &args), "%1");
// Lone % before non-digit/non-% is copied verbatim.
assert_eq!(TextGeneratorV3::format_string("%x %", &args), "%x %");
// Trailing % is copied verbatim.
assert_eq!(TextGeneratorV3::format_string("end%", &args), "end%");
}
#[test]
fn format_string_out_of_int_range_fails_to_zero() {
let args = vec!["foo".to_string()];
assert_eq!(
TextGeneratorV3::format_string("%99999999999999999999", &args),
""
);
assert_eq!(TextGeneratorV3::format_string("%2147483648", &args), "");
assert_eq!(TextGeneratorV3::format_string("%2147483647", &args), "");
}
#[test]
fn format_string_multidigit_and_reuse() {
let args = vec!["a".to_string(), "b".to_string(), "c".to_string()];
// %10 parses as index 10 (out of range with 3 args) -> empty.
assert_eq!(TextGeneratorV3::format_string("%10", &args), "");
assert_eq!(TextGeneratorV3::format_string("%2%2%2", &args), "bbb");
}
#[test]
fn layout_request_uses_olive_html_and_96dpi() {
let mut row = NodeValueRow::default();
row.insert(
TEXT_INPUT.to_string(),
NodeValue::Text("
Hi
".to_string()),
);
row.insert(
crate::nodes::shapenodebase::SIZE_INPUT.to_string(),
NodeValue::Vec2([400.0, 300.0]),
);
// The legacy path is driven explicitly: the backend state is a
// process-global that other tests install hooks into.
let req = TextGeneratorV3::layout_request_path(false, &row);
assert_eq!(req.text, "
Hi
");
assert_eq!(req.mode, TextLayoutMode::OliveHtml);
assert_eq!(req.dots_per_meter, 3780);
assert_eq!(req.wrap_width, 400.0);
}
#[test]
fn layout_request_plain_text_path_uses_structured_inputs() {
let mut row = NodeValueRow::default();
row.insert(
PLAIN_TEXT_INPUT.to_string(),
NodeValue::Text("hello".to_string()),
);
row.insert(
FONT_FAMILY_INPUT.to_string(),
NodeValue::StrCombo("Noto Sans".to_string()),
);
row.insert(FONT_SIZE_INPUT.to_string(), NodeValue::Float(48.0));
row.insert(
crate::nodes::shapenodebase::SIZE_INPUT.to_string(),
NodeValue::Vec2([400.0, 300.0]),
);
// The row also carries legacy HTML: the plain path must ignore it.
row.insert(
TEXT_INPUT.to_string(),
NodeValue::Text("
legacy
".to_string()),
);
let req = TextGeneratorV3::layout_request_path(true, &row);
assert_eq!(req.text, "hello");
assert_eq!(req.mode, TextLayoutMode::PlainText);
assert_eq!(req.font_family, "Noto Sans");
assert_eq!(req.font_size_pt, 48.0);
assert_eq!(req.dots_per_meter, 3780);
assert_eq!(req.wrap_width, 400.0);
}
#[test]
fn job_text_prefers_the_row_value() {
let (core, _behavior) = create();
let mut row = NodeValueRow::default();
row.insert(
PLAIN_TEXT_INPUT.to_string(),
NodeValue::Text("row plain".to_string()),
);
row.insert(
TEXT_INPUT.to_string(),
NodeValue::Text("row html".to_string()),
);
assert_eq!(
TextGeneratorV3::job_text(true, &core, &row, Rational::new(0, 1)),
"row plain"
);
assert_eq!(
TextGeneratorV3::job_text(false, &core, &row, Rational::new(0, 1)),
"row html"
);
}
#[test]
fn job_text_falls_back_to_the_core_value() {
let (mut core, _behavior) = create();
core.set_standard_value(
PLAIN_TEXT_INPUT,
-1,
NodeValue::Text("core plain".to_string()),
);
core.set_standard_value(TEXT_INPUT, -1, NodeValue::Text("core html".to_string()));
let row = NodeValueRow::default();
assert_eq!(
TextGeneratorV3::job_text(true, &core, &row, Rational::new(0, 1)),
"core plain"
);
assert_eq!(
TextGeneratorV3::job_text(false, &core, &row, Rational::new(0, 1)),
"core html"
);
}
#[test]
fn base_and_draw_offsets() {
let size = [400.0, 300.0];
let base = TextGeneratorV3::base_offset([0.0, 0.0], size, 1920, 1080);
assert_eq!(base, (760.0, 390.0));
// Top: no delta; middle/bottom use double math on doc.height.
assert_eq!(
TextGeneratorV3::draw_offset(VerticalAlignment::Top, base, size, 100.0),
base
);
assert_eq!(
TextGeneratorV3::draw_offset(VerticalAlignment::Middle, base, size, 100.0),
(base.0, base.1 + 150.0 - 50.0)
);
assert_eq!(
TextGeneratorV3::draw_offset(VerticalAlignment::Bottom, base, size, 100.0),
(base.0, base.1 + 300.0 - 100.0)
);
}
/// Serializes the tests that install a process-global text backend;
/// shared with the `textbackend` module's own tests, so no test
/// observes another module's install.
use crate::nodes::textbackend::TEST_BACKEND_LOCK as BACKEND_LOCK;
/// Measure hook for tests that only need "a backend is installed".
fn noop_measure(_req: &TextLayoutRequest) -> TextLayoutSize {
TextLayoutSize::default()
}
#[test]
fn measure_without_backend_returns_zero_size() {
let _guard = BACKEND_LOCK.lock().unwrap();
crate::nodes::textbackend::set_text_backends(None, None);
let mut row = NodeValueRow::default();
row.insert(
TEXT_INPUT.to_string(),
NodeValue::Text("
Hi
".to_string()),
);
let (_req, doc) = TextGeneratorV3::measure_and_layout(&row);
assert_eq!(doc.width, 0.0);
assert_eq!(doc.height, 0.0);
}
#[test]
fn value_uses_plain_text_when_a_backend_is_installed() {
let _guard = BACKEND_LOCK.lock().unwrap();
crate::nodes::textbackend::set_text_backends(Some(noop_measure), None);
assert!(TextGeneratorV3::plain_text_path());
// The row carries an empty legacy HTML only: the non-empty plain
// text default is what makes the job push, so this fails on the
// legacy path (empty text, no base -> empty table).
let (core, behavior) = create();
let mut row = NodeValueRow::default();
row.insert(TEXT_INPUT.to_string(), NodeValue::Text(String::new()));
let mut table = NodeValueTable::default();
behavior.value(&core, &row, Rational::new(0, 1), &mut table);
assert!(matches!(
table.get(ValueType::Texture),
Some(NodeValue::Texture(h)) if h.is_null()
));
// The public layout request follows the installed backend.
let mut req_row = NodeValueRow::default();
req_row.insert(
PLAIN_TEXT_INPUT.to_string(),
NodeValue::Text("hi".to_string()),
);
assert_eq!(
TextGeneratorV3::layout_request(&req_row).mode,
TextLayoutMode::PlainText
);
crate::nodes::textbackend::set_text_backends(None, None);
assert!(!TextGeneratorV3::plain_text_path());
}
#[test]
fn value_pushes_job_when_text_nonempty() {
let _guard = BACKEND_LOCK.lock().unwrap();
crate::nodes::textbackend::set_text_backends(None, None);
let (core, behavior) = create();
let mut row = NodeValueRow::default();
row.insert(
TEXT_INPUT.to_string(),
NodeValue::Text("
Hi
".to_string()),
);
row.insert(USE_ARGS_INPUT.to_string(), NodeValue::Boolean(false));
let mut table = NodeValueTable::default();
behavior.value(&core, &row, Rational::new(0, 1), &mut table);
assert!(matches!(
table.get(ValueType::Texture),
Some(NodeValue::Texture(h)) if h.is_null()
));
}
#[test]
fn value_expands_args_from_row() {
let _guard = BACKEND_LOCK.lock().unwrap();
crate::nodes::textbackend::set_text_backends(None, None);
let (core, behavior) = create();
let mut row = NodeValueRow::default();
row.insert(
TEXT_INPUT.to_string(),
NodeValue::Text("Hello %1".to_string()),
);
row.insert(USE_ARGS_INPUT.to_string(), NodeValue::Boolean(true));
row.insert(ARGS_INPUT.to_string(), NodeValue::Text("World".to_string()));
let mut table = NodeValueTable::default();
behavior.value(&core, &row, Rational::new(0, 1), &mut table);
assert!(matches!(
table.get(ValueType::Texture),
Some(NodeValue::Texture(h)) if h.is_null()
));
// The expanded text is carried by the (deferred) job, which has no
// payload here; the expansion math itself is covered by
// format_string tests.
}
#[test]
fn value_passes_base_through_when_text_empty() {
let (core, behavior) = create();
let mut row = NodeValueRow::default();
row.insert(TEXT_INPUT.to_string(), NodeValue::Text(String::new()));
row.insert(
crate::nodes::generatorwithmerge::BASE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
);
let mut table = NodeValueTable::default();
behavior.value(&core, &row, Rational::new(0, 1), &mut table);
assert!(matches!(
table.get(ValueType::Texture),
Some(NodeValue::Texture(_))
));
}
#[test]
fn value_pushes_nothing_when_text_empty_and_no_base() {
let (core, behavior) = create();
let mut row = NodeValueRow::default();
// Both text inputs are emptied explicitly: which one `value` reads
// depends on the process-global backend state, which the installer
// tests change and restore concurrently.
row.insert(PLAIN_TEXT_INPUT.to_string(), NodeValue::Text(String::new()));
row.insert(TEXT_INPUT.to_string(), NodeValue::Text(String::new()));
let mut table = NodeValueTable::default();
behavior.value(&core, &row, Rational::new(0, 1), &mut table);
assert!(table.is_empty());
}
#[test]
fn gizmo_activation_toggles_use_args() {
let (mut core, mut behavior) = create();
let node = behavior
.as_any_mut()
.unwrap()
.downcast_mut::()
.unwrap();
node.gizmo_activated(&mut core);
assert_eq!(
core.standard_value(USE_ARGS_INPUT, -1),
NodeValue::Boolean(false)
);
assert!(node.dont_emit_valign);
node.gizmo_deactivated(&mut core);
assert_eq!(
core.standard_value(USE_ARGS_INPUT, -1),
NodeValue::Boolean(true)
);
assert!(node.dont_emit_valign);
}
#[test]
fn set_vertical_alignment_undoable_maps_through_gizmo_alignment() {
let (mut core, mut behavior) = create();
let node = behavior
.as_any_mut()
.unwrap()
.downcast_mut::()
.unwrap();
// Gizmo vcenter (2) maps back to Middle (1).
node.set_vertical_alignment_undoable(&mut core, 2);
assert_eq!(
core.standard_value(VERTICAL_ALIGNMENT_INPUT, -1),
NodeValue::Combo(1)
);
}
#[test]
fn generate_frame_is_documented_noop() {
let (core, behavior) = create();
let mut frame = crate::handle::CHandle::null();
behavior.generate_frame(&core, &mut frame, Rational::new(0, 1));
assert!(frame.is_null());
}
#[test]
fn duplicate_copies_node() {
let (_core, behavior) = create();
let copy = behavior.duplicate(&_core).unwrap();
assert_eq!(copy.type_id(), "org.olivevideoeditor.Olive.text3");
assert_eq!(copy.name(), "Text");
}
#[test]
fn redesign_inputs_have_defaults_and_flags() {
let (core, _behavior) = create();
let plain = core.get_input(PLAIN_TEXT_INPUT).unwrap();
assert_eq!(plain.value_type, ValueType::Text);
assert_eq!(
plain.default,
NodeValue::Text(DEFAULT_PLAIN_TEXT.to_string())
);
let family = core.get_input(FONT_FAMILY_INPUT).unwrap();
assert_eq!(family.value_type, ValueType::StrCombo);
assert_eq!(family.default, NodeValue::StrCombo(String::new()));
let size = core.get_input(FONT_SIZE_INPUT).unwrap();
assert_eq!(size.value_type, ValueType::Float);
assert_eq!(size.default, NodeValue::Float(72.0));
assert!(size
.properties
.iter()
.any(|(k, v)| k == "min" && v == &NodeValue::Float(1.0)));
let outline = core.get_input(OUTLINE_ENABLED_INPUT).unwrap();
assert_eq!(outline.value_type, ValueType::Boolean);
assert_eq!(outline.default, NodeValue::Boolean(false));
let outline_color = core.get_input(OUTLINE_COLOR_INPUT).unwrap();
assert_eq!(outline_color.value_type, ValueType::Color);
assert_eq!(
outline_color.default,
NodeValue::Color([0.0, 0.0, 0.0, 1.0])
);
let outline_width = core.get_input(OUTLINE_WIDTH_INPUT).unwrap();
assert_eq!(outline_width.value_type, ValueType::Float);
assert_eq!(outline_width.default, NodeValue::Float(2.0));
assert!(outline_width
.properties
.iter()
.any(|(k, v)| k == "min" && v == &NodeValue::Float(0.0)));
let glow = core.get_input(GLOW_ENABLED_INPUT).unwrap();
assert_eq!(glow.value_type, ValueType::Boolean);
assert_eq!(glow.default, NodeValue::Boolean(false));
let glow_color = core.get_input(GLOW_COLOR_INPUT).unwrap();
assert_eq!(glow_color.value_type, ValueType::Color);
assert_eq!(glow_color.default, NodeValue::Color([1.0, 1.0, 0.0, 1.0]));
let glow_radius = core.get_input(GLOW_RADIUS_INPUT).unwrap();
assert_eq!(glow_radius.value_type, ValueType::Float);
assert_eq!(glow_radius.default, NodeValue::Float(8.0));
assert!(glow_radius
.properties
.iter()
.any(|(k, v)| k == "min" && v == &NodeValue::Float(0.0)));
// Every redesign input is user-facing (none hidden).
for id in [
PLAIN_TEXT_INPUT,
FONT_FAMILY_INPUT,
FONT_SIZE_INPUT,
OUTLINE_ENABLED_INPUT,
OUTLINE_COLOR_INPUT,
OUTLINE_WIDTH_INPUT,
GLOW_ENABLED_INPUT,
GLOW_COLOR_INPUT,
GLOW_RADIUS_INPUT,
] {
assert_eq!(
core.get_input(id).unwrap().flags & crate::input::flags::HIDDEN,
0
);
}
// The legacy input keeps its default and vieweronly property, and
// is now hidden.
let legacy = core.get_input(TEXT_INPUT).unwrap();
assert_eq!(
legacy.default,
NodeValue::Text(LEGACY_DEFAULT_TEXT_HTML.to_string())
);
assert_ne!(legacy.flags & crate::input::flags::HIDDEN, 0);
assert!(legacy
.properties
.iter()
.any(|(k, v)| k == "vieweronly" && v == &NodeValue::Boolean(true)));
// The shape base still has no color input of its own
// (ShapeNodeBase(false)); the REDESIGN wave-3 font color input
// takes that exact slot (white default).
assert_eq!(
core.get_input(crate::nodes::shapenodebase::COLOR_INPUT)
.unwrap()
.default,
NodeValue::Color([1.0, 1.0, 1.0, 1.0])
);
}
#[test]
fn strip_html_to_plain_drops_tags() {
assert_eq!(strip_html_to_plain("
a
b
"), "ab");
assert_eq!(strip_html_to_plain(" "), "");
assert_eq!(strip_html_to_plain(""), "");
assert_eq!(strip_html_to_plain("a b"), "ab");
// Whitespace is neither collapsed nor trimmed.
assert_eq!(strip_html_to_plain("
a b
"), "a b");
// A complete tag drops only itself; the text around it stays.
assert_eq!(strip_html_to_plain("
abc"), "abc");
// An unterminated tag swallows the remainder.
assert_eq!(strip_html_to_plain("a\"' b"
);
// A bare '&' and unknown entities are kept verbatim, one pass only.
assert_eq!(strip_html_to_plain("a & b &fake; c"), "a & b &fake; c");
assert_eq!(strip_html_to_plain("&"), "&");
// Entities are decoded after tags are dropped: an encoded tag stays
// literal text.
assert_eq!(strip_html_to_plain("<p>"), "
".to_string()),
);
assert!(migrate_legacy_html(&mut core));
assert_eq!(
core.standard_value(PLAIN_TEXT_INPUT, -1),
NodeValue::Text("Hello World".to_string())
);
// The legacy value is left untouched and further calls are no-ops.
assert_eq!(
core.standard_value(TEXT_INPUT, -1),
NodeValue::Text("
Hello World
".to_string())
);
assert!(!migrate_legacy_html(&mut core));
assert_eq!(
core.standard_value(PLAIN_TEXT_INPUT, -1),
NodeValue::Text("Hello World".to_string())
);
}
#[test]
fn migrate_legacy_html_leaves_defaults_and_edits_alone() {
let (mut core, _behavior) = create();
// A node at its defaults: the legacy default HTML is not migrated
// (a new node must keep the redesign default across a save/load).
assert!(!migrate_legacy_html(&mut core));
assert_eq!(
core.standard_value(PLAIN_TEXT_INPUT, -1),
NodeValue::Text(DEFAULT_PLAIN_TEXT.to_string())
);
// An edited plain text is never overwritten.
core.set_standard_value(PLAIN_TEXT_INPUT, -1, NodeValue::Text("mine".to_string()));
core.set_standard_value(TEXT_INPUT, -1, NodeValue::Text("
legacy
".to_string()));
assert!(!migrate_legacy_html(&mut core));
assert_eq!(
core.standard_value(PLAIN_TEXT_INPUT, -1),
NodeValue::Text("mine".to_string())
);
// A legacy HTML with no text content migrates nothing.
core.set_standard_value(PLAIN_TEXT_INPUT, -1, NodeValue::Text(String::new()));
core.set_standard_value(TEXT_INPUT, -1, NodeValue::Text("".to_string()));
assert!(!migrate_legacy_html(&mut core));
assert_eq!(
core.standard_value(PLAIN_TEXT_INPUT, -1),
NodeValue::Text(String::new())
);
}
#[test]
fn migrate_legacy_html_noop_without_plain_text_input() {
// A pre-redesign core (no plain_text_in at all) must not panic.
let mut core = NodeCore::new();
core.add_input(crate::input::Input::new(
TEXT_INPUT,
ValueType::Text,
NodeValue::Text("
x
".to_string()),
));
assert!(!migrate_legacy_html(&mut core));
}
/// Render hook for the post-process tests: paints the middle half of
/// the target (`x`, `y` in `[dim / 4, 3 * dim / 4)`) solid white — a
/// coverage block whose dilation and box blur are exactly computable.
fn solid_render(
_req: &TextLayoutRequest,
_transform: &TextRenderTransform,
target: TextRenderTarget,
) {
if target.channel_count != 4 {
return;
}
let stride = target.linesize_bytes as usize;
let (w, h) = (target.width as usize, target.height as usize);
for y in h / 4..3 * h / 4 {
for x in w / 4..3 * w / 4 {
let at = y * stride + x * 4;
target.data[at..at + 4].copy_from_slice(&[255; 4]);
}
}
}
/// The evaluation row of the post-process tests: a 16x16 raster with a
/// 2-pixel black outline and/or a 4-pixel yellow glow.
fn post_row(outline: bool, glow: bool) -> NodeValueRow {
let mut row = NodeValueRow::new();
row.insert(
PLAIN_TEXT_INPUT.to_string(),
NodeValue::Text("X".to_string()),
);
row.insert(USE_ARGS_INPUT.to_string(), NodeValue::Boolean(false));
row.insert(
crate::nodes::shapenodebase::SIZE_INPUT.to_string(),
NodeValue::Vec2([16.0, 16.0]),
);
row.insert(
OUTLINE_ENABLED_INPUT.to_string(),
NodeValue::Boolean(outline),
);
row.insert(
OUTLINE_COLOR_INPUT.to_string(),
NodeValue::Color([0.0, 0.0, 0.0, 1.0]),
);
row.insert(OUTLINE_WIDTH_INPUT.to_string(), NodeValue::Float(2.0));
row.insert(GLOW_ENABLED_INPUT.to_string(), NodeValue::Boolean(glow));
row.insert(
GLOW_COLOR_INPUT.to_string(),
NodeValue::Color([1.0, 1.0, 0.0, 1.0]),
);
row.insert(GLOW_RADIUS_INPUT.to_string(), NodeValue::Float(4.0));
row
}
/// Evaluate [`TextGeneratorV3::value`] and return the texture handle it
/// pushed.
fn push_value(core: &NodeCore, behavior: &dyn NodeBehavior, row: &NodeValueRow) -> CHandle {
let mut table = NodeValueTable::default();
behavior.value(core, row, Rational::new(0, 1), &mut table);
match table.get(ValueType::Texture) {
Some(NodeValue::Texture(handle)) => *handle,
other => panic!("expected a texture row, got {other:?}"),
}
}
/// The job payload boxed by a deferred texture handle.
fn job_of(handle: &CHandle) -> &ShaderJobPayload {
unsafe { crate::jobs::shader_job(handle) }
.expect("handle carries a ShaderJobPayload")
}
/// The shader id of the pass a deferred texture handle runs.
fn shader_id_of(handle: &CHandle) -> &str {
&job_of(handle).shader_id
}
/// A texture-typed job param (the effect input or a merge layer).
fn param_texture<'a>(handle: &'a CHandle, input: &str) -> &'a CHandle {
match job_of(handle).params.get(input) {
Some(NodeValue::Texture(tex)) => tex,
other => panic!("param {input:?} is not a texture: {other:?}"),
}
}
/// A float job param.
fn param_float(handle: &CHandle, input: &str) -> f64 {
match job_of(handle).params.get(input) {
Some(NodeValue::Float(f)) => *f,
other => panic!("param {input:?} is not a float: {other:?}"),
}
}
/// A color job param.
fn param_color(handle: &CHandle, input: &str) -> [f64; 4] {
match job_of(handle).params.get(input) {
Some(NodeValue::Color(c)) => *c,
other => panic!("param {input:?} is not a color: {other:?}"),
}
}
/// A vec2 job param.
fn param_vec2(handle: &CHandle, input: &str) -> [f64; 2] {
match job_of(handle).params.get(input) {
Some(NodeValue::Vec2(v)) => *v,
other => panic!("param {input:?} is not a vec2: {other:?}"),
}
}
/// Identity of the refcounted box behind a handle: every clone of a
/// job param addrefs the same box, so equal pointers mean "the same
/// texture was fed to both passes".
fn job_ptr(handle: &CHandle) -> usize {
handle.ctx as usize
}
/// Assert `handle` boxes the 16x16 CPU coverage frame the rasterizer
/// staging-allocates (not a shader job).
fn assert_cpu_texture(handle: &CHandle) {
match unsafe { crate::handle::get_checked::(handle) } {
Some(Texture::Cpu(frame)) => assert_eq!((frame.width, frame.height), (16, 16)),
other => panic!("expected a CPU coverage frame, got {other:?}"),
}
}
/// One pixel of a CPU coverage frame's F32 RGBA data.
fn pixel_of(handle: &CHandle, x: usize, y: usize) -> [f32; 4] {
let Some(Texture::Cpu(frame)) = (unsafe { crate::handle::get_checked::(handle) })
else {
panic!("expected a CPU coverage frame");
};
let stride = frame.linesize_bytes();
let at = y * stride + x * 16;
let mut out = [0f32; 4];
for (c, v) in out.iter_mut().enumerate() {
*v = f32::from_le_bytes(frame.data[at + c * 4..at + c * 4 + 4].try_into().unwrap());
}
out
}
#[test]
fn post_job_off_still_rasterizes_the_plain_text() {
let _guard = BACKEND_LOCK.lock().unwrap();
crate::nodes::textbackend::set_text_backends(Some(noop_measure), Some(solid_render));
let (core, behavior) = create();
let row = post_row(false, false);
let handle = push_value(&core, behavior.as_ref(), &row);
crate::nodes::textbackend::set_text_backends(None, None);
// Both passes off with a backend installed: the plain (tinted)
// raster, not the pre-backend deferred null job.
assert_cpu_texture(&handle);
assert_eq!(pixel_of(&handle, 8, 8), [1.0, 1.0, 1.0, 1.0]);
assert_eq!(pixel_of(&handle, 0, 0), [0.0, 0.0, 0.0, 0.0]);
}
#[test]
fn font_color_tints_the_raster_premultiplied() {
let _guard = BACKEND_LOCK.lock().unwrap();
crate::nodes::textbackend::set_text_backends(Some(noop_measure), Some(solid_render));
let (core, behavior) = create();
let mut row = post_row(false, false);
row.insert(
COLOR_INPUT.to_string(),
NodeValue::Color([1.0, 0.0, 0.0, 0.5]),
);
let handle = push_value(&core, behavior.as_ref(), &row);
crate::nodes::textbackend::set_text_backends(None, None);
assert_cpu_texture(&handle);
// The white coverage scales per channel, alpha included.
assert_eq!(pixel_of(&handle, 8, 8), [1.0, 0.0, 0.0, 0.5]);
assert_eq!(pixel_of(&handle, 0, 0), [0.0, 0.0, 0.0, 0.0]);
}
#[test]
fn outline_chain_dilates_then_colorizes_over_the_text() {
let _guard = BACKEND_LOCK.lock().unwrap();
crate::nodes::textbackend::set_text_backends(Some(noop_measure), Some(solid_render));
let (core, behavior) = create();
let row = post_row(true, false);
let handle = push_value(&core, behavior.as_ref(), &row);
crate::nodes::textbackend::set_text_backends(None, None);
// The pushed chain is the stroke merged with the text on top: the
// text is the merge's top (`blend_in`) layer, its CPU coverage
// raster the head of that branch.
assert_eq!(shader_id_of(&handle), "mrg");
let stroke = param_texture(&handle, crate::nodes::merge::BASE_INPUT);
let text = param_texture(&handle, crate::nodes::merge::BLEND_INPUT);
assert_cpu_texture(text);
// The stroke is the colorized dilation of the raster.
assert_eq!(shader_id_of(stroke), OUTLINE_COLORIZE_SHADER_ID);
assert_eq!(
param_color(stroke, OUTLINE_COLOR_INPUT),
[0.0, 0.0, 0.0, 1.0]
);
assert_eq!(param_vec2(stroke, RESOLUTION_INPUT), [16.0, 16.0]);
let dilated = param_texture(stroke, POST_TEXTURE_INPUT);
assert_eq!(shader_id_of(dilated), OUTLINE_DILATE_SHADER_ID);
assert_eq!(job_of(dilated).iterations, 1);
assert_eq!(param_float(dilated, OUTLINE_WIDTH_INPUT), 2.0);
assert_eq!(param_vec2(dilated, RESOLUTION_INPUT), [16.0, 16.0]);
assert_eq!(
job_ptr(param_texture(dilated, POST_TEXTURE_INPUT)),
job_ptr(text)
);
}
#[test]
fn glow_chain_blurs_once_per_axis_then_colorizes() {
let _guard = BACKEND_LOCK.lock().unwrap();
crate::nodes::textbackend::set_text_backends(Some(noop_measure), Some(solid_render));
let (core, behavior) = create();
let row = post_row(false, true);
let handle = push_value(&core, behavior.as_ref(), &row);
crate::nodes::textbackend::set_text_backends(None, None);
// Glow only: the glow is merged beneath the bare text.
assert_eq!(shader_id_of(&handle), "mrg");
let glow = param_texture(&handle, crate::nodes::merge::BASE_INPUT);
let text = param_texture(&handle, crate::nodes::merge::BLEND_INPUT);
assert_cpu_texture(text);
assert_eq!(shader_id_of(glow), GLOW_COLORIZE_SHADER_ID);
assert_eq!(param_color(glow, GLOW_COLOR_INPUT), [1.0, 1.0, 0.0, 1.0]);
assert_eq!(param_vec2(glow, RESOLUTION_INPUT), [16.0, 16.0]);
let blurred = param_texture(glow, POST_TEXTURE_INPUT);
assert_eq!(shader_id_of(blurred), GLOW_BLUR_SHADER_ID);
// Two iterations: one per axis (horizontal, then vertical).
assert_eq!(job_of(blurred).iterations, 2);
assert_eq!(param_float(blurred, GLOW_RADIUS_INPUT), 4.0);
assert_eq!(param_vec2(blurred, RESOLUTION_INPUT), [16.0, 16.0]);
assert_eq!(
job_ptr(param_texture(blurred, POST_TEXTURE_INPUT)),
job_ptr(text)
);
}
#[test]
fn outline_and_glow_glow_the_stroke() {
let _guard = BACKEND_LOCK.lock().unwrap();
crate::nodes::textbackend::set_text_backends(Some(noop_measure), Some(solid_render));
let (core, behavior) = create();
let row = post_row(true, true);
let handle = push_value(&core, behavior.as_ref(), &row);
crate::nodes::textbackend::set_text_backends(None, None);
// Both on: the glow is drawn over the stroke (which is drawn over
// the text), and it samples the stroke itself — the blur's input
// is the stroke's merge job, not the bare coverage raster.
assert_eq!(shader_id_of(&handle), "mrg");
let glow = param_texture(&handle, crate::nodes::merge::BASE_INPUT);
let stroke = param_texture(&handle, crate::nodes::merge::BLEND_INPUT);
assert_eq!(shader_id_of(stroke), "mrg");
let stroke_colorized = param_texture(stroke, crate::nodes::merge::BASE_INPUT);
assert_eq!(shader_id_of(stroke_colorized), OUTLINE_COLORIZE_SHADER_ID);
assert_cpu_texture(param_texture(stroke, crate::nodes::merge::BLEND_INPUT));
let blurred = param_texture(glow, POST_TEXTURE_INPUT);
assert_eq!(shader_id_of(blurred), GLOW_BLUR_SHADER_ID);
assert_eq!(
job_ptr(param_texture(blurred, POST_TEXTURE_INPUT)),
job_ptr(stroke)
);
}
}