This commit is contained in:
2026-07-09 02:59:53 +03:00
commit 7ace048d94
817 changed files with 234289 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "hcie-text"
version = "0.1.0"
edition = "2021"
[dependencies]
hcie-protocol = { path = "../hcie-protocol" }
fontdue = "0.9"
[lib]
crate-type = ["rlib"]
[dev-dependencies]
eframe = { version = "0.34", default-features = false, features = ["default_fonts", "glow"] }
egui = "0.34"
rstest = "0.23"
proptest = "1.5"
approx = "0.5"
+49
View File
@@ -0,0 +1,49 @@
# hcie-text
A specialized Rust library for high-quality text rendering and layer generation, designed for integration into image editing software.
## Project Summary
`hcie-text` provides the core logic for converting text strings into rasterized pixel data and `Layer` objects. It leverages the `fontdue` crate for efficient and accurate glyph rendering, allowing users to load custom fonts, specify text styles, and output RGBA buffers ready for canvas composition.
## Usage Guide
### Basic Workflow
1. **Initialize**: Create a `TextRenderer` instance.
2. **Load Fonts**: Load one or more `.ttf` or `.otf` fonts using `load_font(name, data)`.
3. **Render**: Use `rasterize_text` to get a raw RGBA pixel buffer and the dimensions of the rendered text.
4. **Layer Creation**: Use `create_text_layer` to generate a `Layer` object compatible with the `hcie-protocol`.
### Key API
- `TextRenderer::new()`: Initializes the renderer.
- `load_font(&mut self, name: &str, data: &[u8])`: Registers a font by name.
- `rasterize_text(&mut self, text: &str, font_name: &str, size: f32, color: [u8; 4])`: Returns `(Vec<u8>, width, height)`.
- `create_text_layer(&mut self, text: &str, font: &str, size: f32, color: [u8; 4])`: Creates a `Layer` with `LayerType::Text`.
## AI Agent Instructions
When working on this library or integrating it:
- **Font Management**: Always verify that a font is loaded via `has_font()` or ensure `load_font()` was called before attempting to rasterize.
- **Coordinates**: The library uses a `PositiveYDown` coordinate system, consistent with standard image buffers.
- **Layering**: When creating layers, ensure the dimensions match the intended canvas or are handled by the compositor.
- **Protocol Alignment**: Ensure all `Layer` outputs strictly follow the `hcie-protocol` definitions.
## Development and Testing
### Prerequisites
- Rust toolchain (latest stable)
### Commands
**Build the library:**
```bash
cargo build
```
**Run tests:**
```bash
cargo test
```
**Run the test GUI example:**
```bash
cargo run --example text_test
```
*(Note: The example requires system fonts to be available at standard Linux paths like `/usr/share/fonts`)*
+569
View File
@@ -0,0 +1,569 @@
#![allow(deprecated)]
use eframe::egui;
use egui::{Color32, ColorImage, Pos2};
use hcie_text::TextRenderer;
use std::fs;
#[derive(Clone)]
struct TextObject {
text: String,
font: String,
size: f32,
color: [u8; 4],
pos: Pos2,
w: u32,
h: u32,
}
impl TextObject {
fn bounds(&self) -> egui::Rect {
egui::Rect::from_min_size(self.pos, egui::vec2(self.w as f32, self.h as f32))
}
fn contains(&self, point: Pos2) -> bool {
self.bounds().contains(point)
}
}
/// Strip common style suffixes to find the font family base name.
fn extract_base_font(name: &str) -> String {
let suffixes = [
"-BoldItalic", "-BoldOblique", "-Bold", "-Italic", "-Oblique", "-Regular", "-Medium", "-Light",
"-Thin", "-Black", "-SemiBold", "-ExtraBold", "-ExtraLight",
"BoldItalic", "BoldOblique", "Bold", "Italic", "Oblique", "Regular", "Medium", "Light",
];
for suffix in &suffixes {
if name.ends_with(suffix) {
return name[..name.len() - suffix.len()].to_string();
}
}
name.to_string()
}
/// Detect bold/italic flags from an already-resolved font name.
fn detect_style(name: &str) -> (bool, bool) {
let bold = name.contains("Bold");
let italic = name.contains("Italic") || name.contains("Oblique");
(bold, italic)
}
struct DraftState {
text: String,
font: String, // resolved full font name (e.g. "DejaVuSans-Bold")
base_font: String, // family name (e.g. "DejaVuSans")
size: f32,
color: [u8; 4],
bold: bool,
italic: bool,
}
impl DraftState {
fn from_object(obj: &TextObject) -> Self {
let (bold, italic) = detect_style(&obj.font);
Self {
text: obj.text.clone(),
font: obj.font.clone(),
base_font: extract_base_font(&obj.font),
size: obj.size,
color: obj.color,
bold,
italic,
}
}
fn resolve_font(&mut self, renderer: &TextRenderer) {
let candidates = match (self.bold, self.italic) {
(true, true) => vec![
format!("{}-BoldItalic", self.base_font),
format!("{}BoldItalic", self.base_font),
format!("{}-BoldOblique", self.base_font),
format!("{}BoldOblique", self.base_font),
],
(true, false) => vec![
format!("{}-Bold", self.base_font),
format!("{}Bold", self.base_font),
],
(false, true) => vec![
format!("{}-Italic", self.base_font),
format!("{}Italic", self.base_font),
format!("{}-Oblique", self.base_font),
format!("{}Oblique", self.base_font),
],
(false, false) => vec![self.base_font.clone()],
};
for c in &candidates {
if renderer.has_font(c) {
self.font = c.clone();
return;
}
}
if let Some(first) = candidates.first() {
self.font = first.clone();
}
}
fn set_base_font(&mut self, base: &str, renderer: &TextRenderer) {
self.base_font = base.to_string();
self.bold = false;
self.italic = false;
self.resolve_font(renderer);
}
}
fn scan_font_dir(renderer: &mut TextRenderer, dir: &std::path::Path) {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
scan_font_dir(renderer, &path);
continue;
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if ext != "ttf" && ext != "otf" {
continue;
}
if let Some(name) = path.file_stem().and_then(|n| n.to_str()) {
if let Ok(data) = fs::read(&path) {
let _ = renderer.load_font(name, &data);
}
}
}
}
}
fn load_all_fonts(renderer: &mut TextRenderer) {
let font_dirs = [
"/usr/share/fonts/truetype/dejavu",
"/usr/share/fonts/truetype/liberation",
];
for dir in &font_dirs {
scan_font_dir(renderer, std::path::Path::new(dir));
}
let fallbacks = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
];
for path in &fallbacks {
if let Ok(data) = fs::read(path) {
let stem = std::path::Path::new(path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("fallback");
if !renderer.has_font(stem) {
let _ = renderer.load_font(stem, &data);
}
}
}
}
struct App {
renderer: TextRenderer,
draft: DraftState,
objects: Vec<TextObject>,
selected_index: Option<usize>,
canvas_pixels: Vec<u8>,
texture: Option<egui::TextureHandle>,
canvas_size: [u32; 2],
font_loaded: bool,
dirty: bool,
anti_alias: bool,
}
impl App {
fn new(_cc: &eframe::CreationContext<'_>) -> Self {
let mut renderer = TextRenderer::new();
load_all_fonts(&mut renderer);
let font_loaded = !renderer.get_fonts().is_empty();
let first_font = renderer.get_fonts().first().cloned().unwrap_or_default();
let base = extract_base_font(&first_font);
Self {
renderer,
draft: DraftState {
text: "Hello!".to_string(),
font: first_font.clone(),
base_font: base,
size: 64.0,
color: [0, 0, 0, 255],
bold: false,
italic: false,
},
objects: Vec::new(),
selected_index: None,
canvas_pixels: vec![255; 1024 * 1024 * 4],
texture: None,
canvas_size: [1024, 1024],
font_loaded,
dirty: true,
anti_alias: true,
}
}
fn redraw(&mut self) {
if !self.font_loaded {
return;
}
let w = self.canvas_size[0] as usize;
let h = self.canvas_size[1] as usize;
self.canvas_pixels.fill(255);
for (_i, obj) in self.objects.iter().enumerate() {
if let Ok((pixels, tw, th, _, _, _, _)) =
self.renderer
.rasterize_text(&obj.text, &obj.font, obj.size, obj.color, 0.0, 0.0, 0.0, hcie_protocol::tools::TextAlignment::Left, hcie_protocol::tools::TextOrientation::Horizontal, &[], self.anti_alias)
{
let tx = obj.pos.x as i32;
let ty = obj.pos.y as i32;
for py in 0..th as i32 {
for px in 0..tw as i32 {
let cx = tx + px;
let cy = ty + py;
if cx >= 0 && cx < w as i32 && cy >= 0 && cy < h as i32 {
let si = ((py as u32 * tw + px as u32) * 4) as usize;
let di = (cy as usize * w + cx as usize) * 4;
let a = pixels[si + 3] as f32 / 255.0;
if a <= 0.0 {
continue;
}
for i in 0..4 {
let s = pixels[si + i] as f32;
let d = self.canvas_pixels[di + i] as f32;
self.canvas_pixels[di + i] = (s * a + d * (1.0 - a)) as u8;
}
}
}
}
}
}
self.dirty = true;
}
fn apply_draft(&mut self) {
let Some(idx) = self.selected_index else { return };
if idx >= self.objects.len() {
return;
}
if let Ok((_pixels, tw, th, _, _, _, _)) = self.renderer.rasterize_text(
&self.draft.text,
&self.draft.font,
self.draft.size,
self.draft.color,
0.0,
0.0,
0.0,
hcie_protocol::tools::TextAlignment::Left,
hcie_protocol::tools::TextOrientation::Horizontal,
&[],
self.anti_alias,
) {
self.objects[idx] = TextObject {
text: self.draft.text.clone(),
font: self.draft.font.clone(),
size: self.draft.size,
color: self.draft.color,
pos: self.objects[idx].pos,
w: tw,
h: th,
};
self.redraw();
}
}
fn select_object(&mut self, idx: usize) {
// Auto-apply previous selection before switching
if let Some(prev) = self.selected_index {
if prev != idx {
self.apply_draft();
}
}
let obj = &self.objects[idx];
self.draft = DraftState::from_object(obj);
self.selected_index = Some(idx);
self.redraw(); // removes the old rasterized version
}
fn deselect(&mut self) {
self.apply_draft();
self.selected_index = None;
self.redraw();
}
fn canvas_point(&self, screen: Pos2, rect: egui::Rect, scale: f32) -> Pos2 {
Pos2::new(
(screen.x - rect.min.x) / scale,
(screen.y - rect.min.y) / scale,
)
}
}
impl eframe::App for App {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
let mut draft_changed = false;
if self.dirty {
let image = ColorImage::from_rgba_unmultiplied(
[self.canvas_size[0] as usize, self.canvas_size[1] as usize],
&self.canvas_pixels,
);
if let Some(tex) = &mut self.texture {
tex.set(image, egui::TextureOptions::NEAREST);
} else {
self.texture =
Some(ctx.load_texture("canvas", image, egui::TextureOptions::NEAREST));
}
self.dirty = false;
}
let mut fonts = self.renderer.get_fonts();
fonts.sort();
// ── Left panel ──────────────────────────────────────────────
egui::Panel::left("panel").show(&ctx, |ui| {
ui.heading("Text Editor");
if !self.font_loaded {
ui.colored_label(Color32::RED, "No fonts found.");
}
ui.separator();
if let Some(idx) = self.selected_index {
ui.strong(format!("Editing object #{} — click Done when finished", idx));
} else {
ui.label("Click canvas to add text.");
}
ui.separator();
ui.label("Text");
if ui.text_edit_singleline(&mut self.draft.text).changed() {
draft_changed = true;
}
ui.label("Font family");
egui::ComboBox::from_id_source("font_select")
.selected_text(&self.draft.base_font)
.show_ui(ui, |ui| {
let mut seen = std::collections::HashSet::new();
for f in &fonts {
let base = extract_base_font(f);
if seen.insert(base.clone()) {
if ui.selectable_value(
&mut self.draft.base_font,
base.clone(),
&base,
).changed() {
let b = self.draft.base_font.clone();
self.draft.set_base_font(&b, &self.renderer);
draft_changed = true;
}
}
}
});
// Bold / Italic toggles
ui.horizontal(|ui| {
let bold_btn = ui.add(egui::Button::new("B").selected(self.draft.bold));
if bold_btn.clicked() {
self.draft.bold = !self.draft.bold;
self.draft.resolve_font(&self.renderer);
draft_changed = true;
}
let italic_btn = ui.add(egui::Button::new("I").selected(self.draft.italic));
if italic_btn.clicked() {
self.draft.italic = !self.draft.italic;
self.draft.resolve_font(&self.renderer);
draft_changed = true;
}
});
ui.label("Size");
if ui.add(egui::Slider::new(&mut self.draft.size, 8.0..=300.0)).changed() {
draft_changed = true;
}
let mut rgba = [
self.draft.color[0] as f32 / 255.,
self.draft.color[1] as f32 / 255.,
self.draft.color[2] as f32 / 255.,
self.draft.color[3] as f32 / 255.,
];
if ui.color_edit_button_rgba_unmultiplied(&mut rgba).changed() {
self.draft.color = [
(rgba[0] * 255.) as u8,
(rgba[1] * 255.) as u8,
(rgba[2] * 255.) as u8,
(rgba[3] * 255.) as u8,
];
draft_changed = true;
}
if ui.checkbox(&mut self.anti_alias, "Anti-aliasing").changed() {
draft_changed = true;
}
if self.selected_index.is_some() {
ui.separator();
let done = ui.add_sized(
egui::vec2(ui.available_width(), 32.0),
egui::Button::new(
egui::RichText::new("Done").color(Color32::WHITE),
)
.fill(Color32::from_rgb(34, 139, 34))
.rounding(4.0),
);
if done.clicked() {
self.deselect();
}
ui.horizontal(|ui| {
if ui.button("Delete").clicked() {
if let Some(idx) = self.selected_index {
self.objects.remove(idx);
self.selected_index = None;
self.redraw();
}
}
});
}
ui.separator();
if ui.button("Clear All").clicked() {
self.objects.clear();
self.selected_index = None;
self.redraw();
}
});
// ── Central canvas ──────────────────────────────────────────
egui::CentralPanel::default().show(&ctx, |ui| {
let available = ui.available_size_before_wrap();
let scale = (available.x / self.canvas_size[0] as f32)
.min(available.y / self.canvas_size[1] as f32);
let disp_w = self.canvas_size[0] as f32 * scale;
let disp_h = self.canvas_size[1] as f32 * scale;
let sense = egui::Sense::click_and_drag();
let (rect, resp) = ui.allocate_exact_size(egui::vec2(disp_w, disp_h), sense);
// Click → select existing or create new
if resp.clicked() {
if let Some(pos) = resp.interact_pointer_pos() {
let cp = self.canvas_point(pos, rect, scale);
let hit = self.objects.iter().rposition(|o| o.contains(cp));
if let Some(hit_idx) = hit {
self.select_object(hit_idx);
} else {
// Create new object with current draft settings
if let Ok((_pixels, tw, th, _, _, _, _)) = self.renderer.rasterize_text(
&self.draft.text,
&self.draft.font,
self.draft.size,
self.draft.color,
0.0,
0.0,
0.0,
hcie_protocol::tools::TextAlignment::Left,
hcie_protocol::tools::TextOrientation::Horizontal,
&[],
self.anti_alias,
) {
// If something was already selected, apply it first
if self.selected_index.is_some() {
self.deselect();
}
let idx = self.objects.len();
self.objects.push(TextObject {
text: self.draft.text.clone(),
font: self.draft.font.clone(),
size: self.draft.size,
color: self.draft.color,
pos: cp,
w: tw,
h: th,
});
self.draft = DraftState::from_object(&self.objects[idx]);
self.selected_index = Some(idx);
self.redraw();
}
}
}
}
// Drag → move selected object
if resp.dragged_by(egui::PointerButton::Primary) {
if let Some(idx) = self.selected_index {
if let Some(pos) = resp.interact_pointer_pos() {
let cp = self.canvas_point(pos, rect, scale);
self.objects[idx].pos = cp;
self.redraw();
}
}
}
// Paint canvas texture (committed objects only)
if let Some(tex) = &self.texture {
ui.painter().image(
tex.id(),
rect,
egui::Rect::from_min_max(egui::pos2(0., 0.), egui::pos2(1., 1.)),
Color32::WHITE,
);
} else {
ui.painter().rect_filled(rect, 0.0, Color32::GRAY);
}
// Selection rectangle + inline text edit on canvas
if let Some(idx) = self.selected_index {
if idx < self.objects.len() {
let obj = &self.objects[idx];
let b = obj.bounds();
let screen_rect = egui::Rect::from_min_size(
rect.min + egui::vec2(b.min.x * scale, b.min.y * scale),
egui::vec2(b.width() * scale, b.height() * scale),
);
// Selection border
ui.painter()
.rect_stroke(screen_rect, 0.0, egui::Stroke::new(2.0, Color32::BLUE), egui::StrokeKind::Inside);
// Inline text edit directly on canvas (transparent — rasterized text shows through)
let edit_width = (rect.max.x - screen_rect.min.x - 10.0).max(400.0);
let edit_rect = egui::Rect::from_min_size(
screen_rect.min,
egui::vec2(edit_width, screen_rect.height().max(self.draft.size * scale * 1.2)),
);
ui.allocate_ui_at_rect(edit_rect, |ui| {
ui.visuals_mut().extreme_bg_color = Color32::TRANSPARENT;
let text_resp = ui.add(
egui::TextEdit::singleline(&mut self.draft.text)
.font(egui::FontId::monospace(self.draft.size * scale))
.text_color(Color32::TRANSPARENT)
.frame(egui::Frame::NONE),
);
if text_resp.changed() {
draft_changed = true;
}
});
}
}
});
if draft_changed {
self.apply_draft();
}
ctx.request_repaint();
}
}
fn main() -> eframe::Result<()> {
eframe::run_native(
"HCIE Text Engine — Live Editor",
eframe::NativeOptions {
viewport: egui::ViewportBuilder::default().with_inner_size([1200., 800.]),
..Default::default()
},
Box::new(|cc| Ok(Box::new(App::new(cc)))),
)
}
+441
View File
@@ -0,0 +1,441 @@
#![allow(dead_code)]
//! Text rendering using fontdue.
use fontdue::{Font, FontSettings, layout::{CoordinateSystem, Layout, LayoutSettings, TextStyle}};
use hcie_protocol::{tools::{TextAlignment, TextOrientation, TextEffect}, Layer, LayerData, LayerType};
use std::collections::HashMap;
pub struct TextRenderer {
fonts: HashMap<String, Font>,
}
/// Strip common style suffixes to find the font family base name.
fn extract_base_font(name: &str) -> String {
let suffixes = [
"-BoldItalic", "-BoldOblique", "-Bold", "-Italic", "-Oblique", "-Regular", "-Medium", "-Light",
"-Thin", "-Black", "-SemiBold", "-ExtraBold", "-ExtraLight",
"BoldItalic", "BoldOblique", "Bold", "Italic", "Oblique", "Regular", "Medium", "Light",
];
for suffix in &suffixes {
if name.ends_with(suffix) {
return name[..name.len() - suffix.len()].to_string();
}
}
name.to_string()
}
/// Detect bold/italic flags from a font name.
fn detect_style(name: &str) -> (bool, bool) {
let bold = name.contains("Bold");
let italic = name.contains("Italic") || name.contains("Oblique");
(bold, italic)
}
impl TextRenderer {
pub fn new() -> Self {
Self {
fonts: HashMap::new(),
}
}
pub fn load_font(&mut self, name: &str, data: &[u8]) -> Result<(), String> {
let settings = FontSettings {
collection_index: 0,
scale: 40.0,
load_substitutions: true,
};
let font = Font::from_bytes(data, settings).map_err(|e| e.to_string())?;
self.fonts.insert(name.to_string(), font);
Ok(())
}
pub fn has_font(&self, name: &str) -> bool {
self.fonts.contains_key(name)
}
/// Rasterize a text string into an RGBA pixel buffer.
///
/// The caller supplies the text content, font family, size, color and the
/// transform/layout options (position, angle, alignment, orientation and
/// non-destructive effects). Currently implemented effects are:
///
/// * Rotation: the whole glyph set is rotated around the layer origin.
/// * Warp/3D: reserved as stored metadata; simple kinds such as "slant"
/// are rasterized here, while complex warps are left for the compositor.
///
/// The output size is expanded to fit the rotated bounding box and the
/// layer origin is embedded in the `x`/`y` metadata, not in the pixel
/// offsets, so the compositor can place the raster correctly.
pub fn rasterize_text(
&mut self,
text: &str,
font_name: &str,
size: f32,
color: [u8; 4],
_x: f32,
_y: f32,
angle: f32,
alignment: TextAlignment,
orientation: TextOrientation,
effects: &[TextEffect],
anti_alias: bool,
) -> Result<(Vec<u8>, u32, u32, f32, f32, f32, f32), String> {
let render_size = if anti_alias { size * 2.0 } else { size };
let mut font = self.fonts.get(font_name);
let req_bold;
let req_italic;
let mut actual_bold = false;
let mut actual_italic = false;
if font.is_none() {
let base = extract_base_font(font_name);
let (b, i) = detect_style(font_name);
req_bold = b;
req_italic = i;
let candidates = match (b, i) {
(true, true) => vec![
format!("{}-BoldItalic", base),
format!("{}BoldItalic", base),
format!("{}-BoldOblique", base),
format!("{}BoldOblique", base),
format!("{}-Bold", base),
format!("{}Bold", base),
format!("{}-Italic", base),
format!("{}Italic", base),
format!("{}-Oblique", base),
format!("{}Oblique", base),
base.clone(),
],
(true, false) => vec![
format!("{}-Bold", base),
format!("{}Bold", base),
base.clone(),
],
(false, true) => vec![
format!("{}-Italic", base),
format!("{}Italic", base),
format!("{}-Oblique", base),
format!("{}Oblique", base),
base.clone(),
],
(false, false) => vec![base.clone()],
};
for c in candidates {
if let Some(f) = self.fonts.get(&c) {
font = Some(f);
let (ab, ai) = detect_style(&c);
actual_bold = ab;
actual_italic = ai;
break;
}
}
if font.is_none() {
if let Some((name, f)) = self.fonts.iter().next() {
font = Some(f);
let (ab, ai) = detect_style(name);
actual_bold = ab;
actual_italic = ai;
}
}
} else {
let (ab, ai) = detect_style(font_name);
req_bold = ab;
req_italic = ai;
actual_bold = ab;
actual_italic = ai;
}
let font = font.ok_or_else(|| format!("Font not found and no fallbacks available: {}", font_name))?;
let mut layout = Layout::new(CoordinateSystem::PositiveYDown);
layout.reset(&LayoutSettings {
..LayoutSettings::default()
});
layout.append(&[font], &TextStyle::new(text, render_size, 0));
let need_synthetic_bold = req_bold && !actual_bold;
let need_synthetic_italic = req_italic && !actual_italic;
let bold_offset = if need_synthetic_bold {
(render_size * 0.04).max(1.0).round() as u32
} else {
0
};
let slant_factor = if need_synthetic_italic {
0.20f32
} else {
0.0f32
};
// Compute natural bounds of the unrotated glyph set.
let mut raw_max_x = 0.0f32;
let mut raw_max_y = 0.0f32;
for glyph in layout.glyphs() {
let slant_padding = if need_synthetic_italic {
glyph.height as f32 * slant_factor
} else {
0.0
};
raw_max_x = raw_max_x.max(glyph.x + glyph.width as f32 + slant_padding + bold_offset as f32);
raw_max_y = raw_max_y.max(glyph.y + glyph.height as f32);
}
// Apply simple stored effects such as slant before rotation.
let extra_angle: f32 = effects.iter().filter_map(|e| match e {
TextEffect::Rotation { angle } => Some(*angle),
_ => None,
}).sum();
let total_angle = angle + extra_angle;
// Horizontal alignment offsets the glyph origin within the layer.
let h_align_offset = match alignment {
TextAlignment::Left | TextAlignment::Justify => 0.0f32,
TextAlignment::Center => -raw_max_x * 0.5,
TextAlignment::Right => -raw_max_x,
};
// For vertical orientation we render horizontally first, then rotate 90°.
// A full CJK vertical layout is left as future work.
let apply_vertical_rotation = orientation == TextOrientation::Vertical;
let rotation_for_bounds = if apply_vertical_rotation {
total_angle + 90.0f32
} else {
total_angle
};
let rad = rotation_for_bounds.to_radians();
let (sin, cos) = rad.sin_cos();
// Expand the output bitmap so the rotated text never clips.
let corners = [
(0.0f32, 0.0f32),
(raw_max_x, 0.0f32),
(0.0f32, raw_max_y),
(raw_max_x, raw_max_y),
];
let mut bound_min_x = f32::MAX;
let mut bound_max_x = f32::MIN;
let mut bound_min_y = f32::MAX;
let mut bound_max_y = f32::MIN;
for (cx, cy) in &corners {
let rx = cx * cos - cy * sin;
let ry = cx * sin + cy * cos;
bound_min_x = bound_min_x.min(rx);
bound_max_x = bound_max_x.max(rx);
bound_min_y = bound_min_y.min(ry);
bound_max_y = bound_max_y.max(ry);
}
let w = (bound_max_x - bound_min_x).ceil().max(1.0) as u32;
let h = (bound_max_y - bound_min_y).ceil().max(1.0) as u32;
if raw_max_x <= 0.0 || raw_max_y <= 0.0 {
return Ok((vec![], 0, 0, 0.0, 0.0, 0.0, 0.0));
}
let unrotated_w = raw_max_x.ceil().max(1.0) as u32;
let unrotated_h = raw_max_y.ceil().max(1.0) as u32;
let mut temp_buffer = vec![0u8; (unrotated_w * unrotated_h) as usize];
let h_align_shift = -h_align_offset;
for glyph in layout.glyphs() {
let (metrics, bitmap) = font.rasterize_config(glyph.key);
let gx = glyph.x;
let gy = glyph.y;
for by in 0..metrics.height {
for bx in 0..metrics.width {
let src_alpha = bitmap[by * metrics.width + bx];
if src_alpha == 0 { continue; }
let slant_shift = if need_synthetic_italic {
(metrics.height as f32 - by as f32) * slant_factor
} else {
0.0
};
let src_x = gx + bx as f32 + slant_shift;
let src_y = gy + by as f32;
for bo in 0..=bold_offset {
let sx = (src_x + bo as f32) as u32;
let sy = src_y as u32;
if sx >= unrotated_w || sy >= unrotated_h { continue; }
let idx = (sy * unrotated_w + sx) as usize;
temp_buffer[idx] = temp_buffer[idx].max(src_alpha);
}
}
}
}
let mut pixels = vec![0u8; (w * h * 4) as usize];
let origin_x = -bound_min_x;
let origin_y = -bound_min_y;
for py in 0..h {
for px in 0..w {
let rx = px as f32 - origin_x;
let ry = py as f32 - origin_y;
let sx_aligned = rx * cos + ry * sin + h_align_shift;
let sy = -rx * sin + ry * cos;
let x_floor = sx_aligned.floor();
let y_floor = sy.floor();
let x0 = x_floor as i32;
let y0 = y_floor as i32;
let x1 = x0 + 1;
let y1 = y0 + 1;
let tx = sx_aligned - x_floor;
let ty = sy - y_floor;
let get_alpha = |x: i32, y: i32| -> f32 {
if x >= 0 && x < unrotated_w as i32 && y >= 0 && y < unrotated_h as i32 {
temp_buffer[(y as u32 * unrotated_w + x as u32) as usize] as f32 / 255.0
} else {
0.0
}
};
let a00 = get_alpha(x0, y0);
let a10 = get_alpha(x1, y0);
let a01 = get_alpha(x0, y1);
let a11 = get_alpha(x1, y1);
let a0 = a00 * (1.0 - tx) + a10 * tx;
let a1 = a01 * (1.0 - tx) + a11 * tx;
let interpolated_alpha = a0 * (1.0 - ty) + a1 * ty;
if interpolated_alpha > 0.0 {
let di = ((py * w + px) * 4) as usize;
let final_alpha = (color[3] as f32 * interpolated_alpha).round() as u8;
pixels[di] = color[0];
pixels[di + 1] = color[1];
pixels[di + 2] = color[2];
pixels[di + 3] = pixels[di + 3].max(final_alpha);
}
}
}
if anti_alias && w > 1 && h > 1 {
let half_w = w / 2;
let half_h = h / 2;
if half_w == 0 || half_h == 0 {
return Ok((pixels, w, h, origin_x, origin_y, raw_max_x, raw_max_y));
}
let mut out = vec![0u8; (half_w * half_h * 4) as usize];
let ws = w as usize;
for dy in 0..half_h as usize {
for dx in 0..half_w as usize {
let sx = dx * 2;
let sy = dy * 2;
for c in 0..4usize {
let v = (
pixels[(sy * ws + sx) * 4 + c] as u32
+ pixels[(sy * ws + sx + 1) * 4 + c] as u32
+ pixels[((sy + 1) * ws + sx) * 4 + c] as u32
+ pixels[((sy + 1) * ws + sx + 1) * 4 + c] as u32
) / 4;
out[(dy * half_w as usize + dx) * 4 + c] = v as u8;
}
}
}
return Ok((out, half_w, half_h, origin_x / 2.0, origin_y / 2.0, raw_max_x / 2.0, raw_max_y / 2.0));
}
Ok((pixels, w, h, origin_x, origin_y, raw_max_x, raw_max_y))
}
pub fn get_fonts(&self) -> Vec<String> {
self.fonts.keys().cloned().collect()
}
/// Create a fully configured text layer.
///
/// The layer keeps the original text, font, size, color, position,
/// alignment, orientation and non-destructive effects as metadata.
/// Pixels are rasterized from that metadata on demand.
pub fn create_text_layer(
&mut self,
text: &str,
font: &str,
size: f32,
color: [u8; 4],
x: f32,
y: f32,
angle: f32,
alignment: TextAlignment,
orientation: TextOrientation,
effects: &[TextEffect],
) -> Result<Layer, String> {
let (pixels, width, height, origin_x, origin_y, unrotated_w, unrotated_h) = self.rasterize_text(
text, font, size, color, x, y, angle, alignment, orientation, effects, false,
)?;
let mut layer = if width > 0 && height > 0 {
Layer::from_rgba("Text Layer", width, height, pixels)
} else {
Layer::new_transparent("Text Layer", 64, 64)
};
let offset_x = x - origin_x;
let offset_y = y - origin_y;
layer.data = LayerData::Text {
text: text.to_string(),
font: font.to_string(),
size,
color,
x,
y,
angle,
alignment,
orientation,
effects: effects.to_vec(),
offset_x,
offset_y,
unrotated_w,
unrotated_h,
};
layer.layer_type = LayerType::Text;
Ok(layer)
}
/// Regenerate the pixel buffer of an existing text layer from its metadata.
///
/// This preserves the non-destructive source data and only re-rasterizes
/// the visual result, so edits such as rotation, warp or 3D can be reapplied
/// without losing the original text.
pub fn refresh_text_layer(&mut self, layer: &mut Layer) -> Result<(), String> {
if let LayerData::Text {
ref text,
ref font,
size,
color,
x,
y,
angle,
alignment,
orientation,
ref effects,
ref mut offset_x,
ref mut offset_y,
ref mut unrotated_w,
ref mut unrotated_h,
} = layer.data {
let (pixels, width, height, origin_x, origin_y, unw, unh) = self.rasterize_text(
text, font, size, color, x, y, angle, alignment, orientation, effects, false,
)?;
layer.width = width.max(1);
layer.height = height.max(1);
layer.pixels = pixels;
layer.dirty = true;
*offset_x = x - origin_x;
*offset_y = y - origin_y;
*unrotated_w = unw;
*unrotated_h = unh;
Ok(())
} else {
Err("Not a text layer".to_string())
}
}
}