init
This commit is contained in:
@@ -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"
|
||||
@@ -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`)*
|
||||
@@ -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)))),
|
||||
)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user