From ae7a0923f6c1b72e82d52245fc97fae1232250cd Mon Sep 17 00:00:00 2001
From: scofano <24534225+scofano@users.noreply.github.com>
Date: Mon, 23 Mar 2026 09:06:52 -0300
Subject: [PATCH] feat: add inline rich text styling support to overlays
Support HTML-like inline tags in text overlays for bold, italic,
line breaks, per-segment colors, and highlight backgrounds.
Update text parsing, font selection, measurement, and wrapping logic
so mixed-style segments render accurately while preserving existing
plain-text behavior, and document the new rich text syntax in README.feat: add inline rich text styling support to overlays
Support HTML-like inline tags in text overlays for bold, italic,
line breaks, per-segment colors, and highlight backgrounds.
Update text parsing, font selection, measurement, and wrapping logic
so mixed-style segments render accurately while preserving existing
plain-text behavior, and document the new rich text syntax in README.
---
README.md | 143 ++++++++++++-
font_utils.py | 84 +++++++-
nodes.py | 582 ++++++++++++++++++++++++++++++++++++++++----------
3 files changed, 695 insertions(+), 114 deletions(-)
diff --git a/README.md b/README.md
index 23dc8d5..476e422 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,7 @@ This module provides the **most feature‑rich and precise text overlay system a
* Completely re‑engineered layout engine
* Pixel‑perfect multiline stroke alignment
* Opacity‑aware backgrounds and shadows
+* Inline rich text with HTML-like tags for bold, italic, per-segment text color, and per-segment highlight backgrounds
* Letter/line spacing, padding, and alignment controls
* Full animation engine (fade + directional movement)
* Batch-aware rendering with smart caching
@@ -44,8 +45,92 @@ This module provides the **most feature‑rich and precise text overlay system a
* Stroke + alpha
* Shadow + alpha + offset
* Rounded background box with padding, color, radius, and alpha
+* Inline rich text styling with HTML-like tags
-### ✔️ 4. Animation System
+### ✔️ 4. Inline Rich Text
+
+The `text` field now supports a small HTML-like subset for styling parts of the text independently.
+
+Supported tags:
+
+* `bold`
+* `italic`
+* `
` for line breaks
+* `red text`
+* `highlighted text`
+* `combined styling`
+
+Supported attributes on ``:
+
+| Attribute | Purpose |
+| --------- | ------- |
+| `color` | Per-segment text color |
+| `fill` | Alias for text color |
+| `fg` | Alias for text color |
+| `bg` | Per-segment highlight/background color |
+| `background` | Alias for background color |
+| `background-color` | Alias for background color |
+| `style` | Inline CSS-like support for `color` and `background-color` |
+
+Examples:
+
+```html
+Hello bold and italic
+red and blue on dark background
+```
+
+Notes:
+
+* This is **not full HTML/CSS rendering** — it is a safe, limited inline styling system.
+* Plain text still works exactly as before.
+* Bold and italic use best-effort font variant matching. If the selected font has no matching variant, the regular font is used.
+* Inline background highlights use the node's `bg_alpha` / `bg_radius` settings for opacity and rounding.
+* Invalid inline colors fall back to the node's normal `fill_color_hex` or `bg_color_hex` values.
+* Unsupported HTML tags are ignored for styling; their text content still renders.
+
+### ✔️ 5. Rich Text Examples
+
+#### Mixed emphasis
+
+```html
+This is bold, italic, and both.
+```
+
+#### Multi-color text
+
+```html
+Red
+Green
+Blue
+```
+
+#### Highlighted words only
+
+```html
+Normal text with highlighted words in the middle.
+```
+
+#### Combined color + highlight + emphasis
+
+```html
+Important label
+```
+
+#### Using inline style
+
+```html
+Styled with inline attributes
+```
+
+#### Multiline rich text
+
+```html
+Title: Episode 01
+Subtitle line
+Now Playing
+```
+
+### ✔️ 6. Animation System
Supports:
@@ -69,13 +154,13 @@ Animation Parameters:
* `animation_opacity_target`
* `pause_frames_before_start`
-### ✔️ 5. Batch-Smart Processing
+### ✔️ 7. Batch-Smart Processing
* Automatically caches layout on first frame
* Maintains perfect consistency across all frames
* For animated batches: animates through first *N* frames, then holds the final pose
-### ✔️ 6. Full Video Support
+### ✔️ 8. Full Video Support
The **Advanced Text Overlay – Video** node:
@@ -143,6 +228,19 @@ This provides a consistent font selection experience across all your nodes.
Connect an image or batch → configure text parameters → render.
+You can enter either plain text:
+
+```text
+Hello world
+Second line
+```
+
+or rich text:
+
+```html
+Hello world
Second line
+```
+
### **Video Node: `Advanced Text Overlay – Video`**
Provide a path to a video → configure overlay → output is written to ComfyUI's output directory.
@@ -155,13 +253,50 @@ Provide a path to a video → configure overlay → output is written to ComfyUI
| Parameter | Description |
| ---------------- | ----------------------------------------- |
-| `text` | Text to draw; supports multiline `\n` |
+| `text` | Text to draw; supports multiline `\n` and inline HTML-like tags such as ``, ``, `
`, and `` |
| `all_caps` | Force uppercase |
| `font` | Font name or file; auto-searches `/fonts` |
| `font_size` | Pixel size |
| `letter_spacing` | Per-character spacing |
| `line_spacing` | Spacing between lines |
+### Rich Text Reference
+
+| Syntax | Result |
+| ------ | ------ |
+| `Text` | Bold text when a matching font variant is available |
+| `Text` | Italic text when a matching font variant is available |
+| `
` | Line break |
+| `Text` | Changes text color for that segment |
+| `Text` | Draws a background highlight behind that segment |
+| `Text` | Combined inline styling |
+
+### Rich Text Examples
+
+```html
+Normal bold italic
+```
+
+```html
+Gold Cyan
+```
+
+```html
+Red highlight and white on blue
+```
+
+```html
+Golden title
+subtitle
+```
+
+### Rich Text Limitations
+
+* This is **not** a full browser engine and does not support general HTML layout or arbitrary CSS.
+* Supported styling is intentionally limited to inline emphasis, line breaks, text color, and segment background color.
+* Per-segment backgrounds wrap naturally with the text layout; if text wraps, the highlight is drawn for each wrapped fragment.
+* Whole-block background settings (`bg_enable`, `bg_padding`, `bg_color_hex`, etc.) still apply independently from inline segment highlights.
+
### Color & Stroke
| Parameter | Description |
diff --git a/font_utils.py b/font_utils.py
index 2544e41..73eccd7 100644
--- a/font_utils.py
+++ b/font_utils.py
@@ -6,6 +6,7 @@ Provides cross-platform font discovery and management functionality.
import os
import sys
import glob
+import re
from typing import List, Dict, Optional
from pathlib import Path
@@ -37,6 +38,82 @@ class FontManager:
self._scan_system_fonts()
return self._name_to_path.get(font_name, font_name)
+
+ def get_font_variant_path(self, font_name: str, bold: bool = False, italic: bool = False) -> str:
+ """
+ Best-effort lookup for bold/italic variants of a selected font.
+
+ If a matching variant cannot be found, returns the regular font path.
+ """
+ if not self._scanned:
+ self._scan_system_fonts()
+
+ base_path = self.get_font_path(font_name)
+ if not bold and not italic:
+ return base_path
+
+ if bold and italic:
+ return (
+ self._find_variant_path(font_name, base_path, bold=True, italic=True)
+ or self._find_variant_path(font_name, base_path, bold=True, italic=False)
+ or self._find_variant_path(font_name, base_path, bold=False, italic=True)
+ or base_path
+ )
+
+ return self._find_variant_path(font_name, base_path, bold=bold, italic=italic) or base_path
+
+ def _normalize_font_name(self, value: str) -> str:
+ return re.sub(r"[^a-z0-9]+", "", (value or "").lower())
+
+ def _find_variant_path(self, font_name: str, base_path: str, bold: bool = False, italic: bool = False) -> Optional[str]:
+ normalized_targets = []
+
+ if font_name:
+ normalized_targets.append(self._normalize_font_name(font_name))
+
+ if base_path and os.path.exists(base_path):
+ base_stem = Path(base_path).stem
+ normalized_targets.append(self._normalize_font_name(base_stem))
+
+ normalized_targets = [t for t in dict.fromkeys(normalized_targets) if t]
+ if not normalized_targets:
+ return None
+
+ bold_markers = ["bold", "semibold", "demibold", "extrabold", "black", "heavy"]
+ italic_markers = ["italic", "oblique", "slanted", "kursiv"]
+
+ best_match = None
+ best_score = None
+
+ for candidate_name, candidate_path in self._name_to_path.items():
+ normalized_name = self._normalize_font_name(candidate_name)
+ if not normalized_name:
+ continue
+
+ if not any(target in normalized_name or normalized_name in target for target in normalized_targets):
+ continue
+
+ if bold and not any(marker in normalized_name for marker in bold_markers):
+ continue
+ if italic and not any(marker in normalized_name for marker in italic_markers):
+ continue
+
+ score = len(normalized_name)
+ if bold and "bold" in normalized_name:
+ score -= 10
+ if italic and "italic" in normalized_name:
+ score -= 10
+ for target in normalized_targets:
+ if normalized_name == target:
+ score -= 2
+ elif normalized_name.startswith(target) or target.startswith(normalized_name):
+ score -= 1
+
+ if best_score is None or score < best_score:
+ best_score = score
+ best_match = candidate_path
+
+ return best_match
def _scan_system_fonts(self) -> None:
"""Platform-specific font discovery."""
@@ -153,4 +230,9 @@ def get_available_fonts() -> List[str]:
def get_font_path(font_name: str) -> str:
"""Convenience function to get font path from name."""
- return font_manager.get_font_path(font_name)
\ No newline at end of file
+ return font_manager.get_font_path(font_name)
+
+
+def get_font_variant_path(font_name: str, bold: bool = False, italic: bool = False) -> str:
+ """Convenience function to get a bold/italic font variant path if available."""
+ return font_manager.get_font_variant_path(font_name, bold=bold, italic=italic)
\ No newline at end of file
diff --git a/nodes.py b/nodes.py
index d72fe5a..0967c75 100644
--- a/nodes.py
+++ b/nodes.py
@@ -1,7 +1,9 @@
import os
+import re
import subprocess
import numpy as np
import torch
+from html.parser import HTMLParser
from PIL import Image, ImageDraw, ImageFont
import imageio.v2 as imageio
@@ -20,7 +22,80 @@ except Exception:
# Relative import so it works as a package module in ComfyUI
from . import animations
-from .font_utils import get_available_fonts, get_font_path
+from .font_utils import get_available_fonts, get_font_variant_path
+
+
+class InlineRichTextParser(HTMLParser):
+ """Parses a small HTML-like subset into styled text runs."""
+
+ def __init__(self):
+ super().__init__(convert_charrefs=True)
+ self.runs = []
+ self._style_stack = [{"bold": False, "italic": False, "fill": None, "bg": None}]
+
+ def handle_starttag(self, tag, attrs):
+ tag = (tag or "").lower()
+ attrs = dict(attrs or [])
+
+ if tag == "br":
+ self.runs.append({"text": "\n", "style": self._style_stack[-1].copy()})
+ return
+
+ new_style = self._style_stack[-1].copy()
+
+ if tag == "b":
+ new_style["bold"] = True
+ elif tag == "i":
+ new_style["italic"] = True
+ elif tag == "span":
+ span_style = self._parse_span_attrs(attrs)
+ for key, value in span_style.items():
+ if value is not None:
+ new_style[key] = value
+
+ self._style_stack.append(new_style)
+
+ def handle_startendtag(self, tag, attrs):
+ tag = (tag or "").lower()
+ self.handle_starttag(tag, attrs)
+ if tag != "br" and len(self._style_stack) > 1:
+ self.handle_endtag(tag)
+
+ def handle_endtag(self, tag):
+ if len(self._style_stack) > 1:
+ self._style_stack.pop()
+
+ def handle_data(self, data):
+ if data:
+ self.runs.append({"text": data, "style": self._style_stack[-1].copy()})
+
+ def _parse_span_attrs(self, attrs):
+ parsed = {"fill": None, "bg": None}
+
+ if attrs.get("color"):
+ parsed["fill"] = attrs.get("color")
+ if attrs.get("fill"):
+ parsed["fill"] = attrs.get("fill")
+ if attrs.get("fg"):
+ parsed["fill"] = attrs.get("fg")
+
+ for key in ("bg", "background", "background-color"):
+ if attrs.get(key):
+ parsed["bg"] = attrs.get(key)
+
+ style_text = attrs.get("style", "") or ""
+ for part in style_text.split(";"):
+ if ":" not in part:
+ continue
+ key, value = part.split(":", 1)
+ key = key.strip().lower()
+ value = value.strip()
+ if key == "color":
+ parsed["fill"] = value
+ elif key in ("background", "background-color"):
+ parsed["bg"] = value
+
+ return parsed
class TextOverlay:
"""
@@ -104,109 +179,315 @@ class TextOverlay:
# ---------------- helpers ----------------
- def hex_to_rgb(self, hex_color: str):
- hex_color = hex_color.strip().lstrip("#")
- if len(hex_color) == 3:
- hex_color = "".join(ch * 2 for ch in hex_color)
- return tuple(int(hex_color[i:i + 2], 16) for i in (0, 2, 4))
+ def hex_to_rgb(self, hex_color: str, fallback=(255, 255, 255)):
+ try:
+ hex_color = (hex_color or "").strip().lstrip("#")
+ if len(hex_color) == 3:
+ hex_color = "".join(ch * 2 for ch in hex_color)
+ if len(hex_color) != 6:
+ return fallback
+ return tuple(int(hex_color[i:i + 2], 16) for i in (0, 2, 4))
+ except Exception:
+ return fallback
def _normalize_text(self, text: str) -> str:
- return text.replace("\\n", "\n").replace("\\N", "\n")
+ return (text or "").replace("\\n", "\n").replace("\\N", "\n")
+
+ def _style_key(self, style):
+ return (
+ bool(style.get("bold")),
+ bool(style.get("italic")),
+ style.get("fill"),
+ style.get("bg"),
+ )
+
+ def _font_signature(self, font_obj):
+ try:
+ return (font_obj.getname(), getattr(font_obj, "path", None), getattr(font_obj, "size", None))
+ except Exception:
+ return (None, getattr(font_obj, "path", None), getattr(font_obj, "size", None))
+
+ def _load_font(self, font, font_size, bold=False, italic=False):
+ if not hasattr(self, "_font_object_cache"):
+ self._font_object_cache = {}
+
+ cache_key = (font, font_size, bool(bold), bool(italic))
+ if cache_key in self._font_object_cache:
+ return self._font_object_cache[cache_key]
+
+ font_path = get_font_variant_path(font, bold=bold, italic=italic)
- def _load_font(self, font, font_size):
- # First try to resolve the font name to a path using our font utils
- font_path = get_font_path(font)
-
- # Then try the local fonts directory as fallback
fonts_dir = os.path.join(os.path.dirname(__file__), "fonts")
local_font_path = os.path.join(fonts_dir, font)
if not os.path.exists(font_path) and os.path.exists(local_font_path):
font_path = local_font_path
-
+
try:
- return ImageFont.truetype(font_path, font_size)
+ loaded = ImageFont.truetype(font_path, font_size)
except Exception as e:
print(f"Error loading font: {e} — using default font")
- return ImageFont.load_default()
+ loaded = ImageFont.load_default()
- def _wrap_lines(self, draw, text, font, max_width, padding, letter_spacing):
- import re
- paragraphs = self._normalize_text(text).split("\n")
- out_lines = []
- for para in paragraphs:
- if para == "":
- out_lines.append("")
+ self._font_object_cache[cache_key] = loaded
+ return loaded
+
+ def _parse_rich_text(self, text, all_caps):
+ normalized = self._normalize_text(text)
+ parser = InlineRichTextParser()
+ default_style = {"bold": False, "italic": False, "fill": None, "bg": None}
+
+ try:
+ parser.feed(normalized)
+ parser.close()
+ parsed_runs = parser.runs or [{"text": normalized, "style": default_style.copy()}]
+ except Exception:
+ parsed_runs = [{"text": normalized, "style": default_style.copy()}]
+
+ merged_runs = []
+ for run in parsed_runs:
+ chunk = run.get("text", "")
+ if all_caps:
+ chunk = chunk.upper()
+ if chunk == "":
continue
- tokens = re.findall(r"\S+|\s+", para)
- line = ""
- for tok in tokens:
- candidate = line + tok
- char_count = max(0, len(candidate) - 1)
- candidate_px = draw.textlength(candidate, font=font) + char_count * letter_spacing
- if candidate_px <= (max_width - 2 * padding):
- line = candidate
- else:
- if line == "":
- out_lines.append(candidate)
- line = ""
- else:
- out_lines.append(line)
- line = tok.lstrip()
- out_lines.append(line)
- return out_lines
+
+ style = run.get("style", default_style).copy()
+ if merged_runs and self._style_key(merged_runs[-1]["style"]) == self._style_key(style):
+ merged_runs[-1]["text"] += chunk
+ else:
+ merged_runs.append({"text": chunk, "style": style})
+
+ return merged_runs
+
+ def _tokenize_runs(self, runs, font_name, font_size):
+ tokens = []
+ for run in runs:
+ style = run["style"].copy()
+ font_obj = self._load_font(
+ font_name,
+ font_size,
+ bold=style.get("bold", False),
+ italic=style.get("italic", False),
+ )
+
+ for part in re.findall(r"\n|[^\S\n]+|\S+", run["text"]):
+ if part == "\n":
+ tokens.append({"text": "\n", "style": style.copy(), "font": font_obj, "newline": True})
+ elif part:
+ tokens.append({"text": part, "style": style.copy(), "font": font_obj, "newline": False})
+ return tokens
+
+ def _merge_line_segments(self, tokens):
+ merged = []
+ for token in tokens:
+ if token.get("newline"):
+ continue
+
+ item = {
+ "text": token.get("text", ""),
+ "style": token.get("style", {}).copy(),
+ "font": token.get("font"),
+ }
+ if not item["text"]:
+ continue
+
+ if merged and self._style_key(merged[-1]["style"]) == self._style_key(item["style"]):
+ merged[-1]["text"] += item["text"]
+ else:
+ merged.append(item)
+
+ return merged
+
+ def _measure_text_advance(self, draw, text, font, letter_spacing):
+ if not text:
+ return 0.0
+
+ if not hasattr(self, "_measure_cache"):
+ self._measure_cache = {}
+
+ cache_key = (self._font_signature(font), text, float(letter_spacing))
+ if cache_key in self._measure_cache:
+ return self._measure_cache[cache_key]
+
+ total = 0.0
+ for i, ch in enumerate(text):
+ total += draw.textlength(ch, font=font)
+ if i < len(text) - 1:
+ total += letter_spacing
+
+ self._measure_cache[cache_key] = total
+ return total
+
+ def _measure_line_width(self, draw, segments, letter_spacing):
+ nonempty = [seg for seg in segments if seg.get("text")]
+ if not nonempty:
+ return 0.0
+
+ total = sum(self._measure_text_advance(draw, seg["text"], seg["font"], letter_spacing) for seg in nonempty)
+ if len(nonempty) > 1:
+ total += letter_spacing * (len(nonempty) - 1)
+ return total
+
+ def _compute_line_metrics(self, draw, segments, stroke_width, default_font):
+ if not hasattr(self, "_line_metric_cache"):
+ self._line_metric_cache = {}
+
+ fonts = [seg["font"] for seg in segments if seg.get("font") is not None]
+ if not fonts:
+ fonts = [default_font]
+
+ tops = []
+ bottoms = []
+ seen = set()
+ for font in fonts:
+ sig = (self._font_signature(font), int(stroke_width))
+ if sig in seen:
+ continue
+ seen.add(sig)
+
+ if sig not in self._line_metric_cache:
+ bbox = draw.textbbox((0, 0), "Ag", font=font, stroke_width=stroke_width)
+ self._line_metric_cache[sig] = (bbox[1], bbox[3], bbox[3] - bbox[1])
+
+ top, bottom, height = self._line_metric_cache[sig]
+ tops.append(top)
+ bottoms.append(bottom)
+
+ line_top = min(tops) if tops else 0
+ line_bottom = max(bottoms) if bottoms else 0
+ return line_top, line_bottom, line_bottom - line_top
+
+ def _split_token_to_fit(self, draw, token, max_width, letter_spacing):
+ text = token.get("text", "")
+ if not text:
+ return None, None
+
+ if text.isspace():
+ return None, None
+
+ split_at = 0
+ for i in range(1, len(text) + 1):
+ candidate = text[:i]
+ width = self._measure_text_advance(draw, candidate, token["font"], letter_spacing)
+ if width <= max_width or i == 1:
+ split_at = i
+ else:
+ break
+
+ split_at = max(1, split_at)
+ head_text = text[:split_at]
+ tail_text = text[split_at:]
+
+ head = token.copy()
+ head["text"] = head_text
+
+ tail = None
+ if tail_text:
+ tail = token.copy()
+ tail["text"] = tail_text
+
+ return head, tail
+
+ def _wrap_styled_lines(self, draw, text, all_caps, font_name, font_size, max_width, letter_spacing):
+ runs = self._parse_rich_text(text, all_caps)
+ tokens = self._tokenize_runs(runs, font_name, font_size)
+
+ if not tokens:
+ return [[]]
+
+ lines = []
+ current = []
+ idx = 0
+ ended_with_newline = False
+ max_width = max(1, int(round(max_width)))
+
+ while idx < len(tokens):
+ token = tokens[idx]
+
+ if token.get("newline"):
+ lines.append(self._merge_line_segments(current))
+ current = []
+ ended_with_newline = True
+ idx += 1
+ continue
+
+ ended_with_newline = False
+
+ if not current and token.get("text", "").isspace():
+ idx += 1
+ continue
+
+ candidate = current + [token]
+ candidate_width = self._measure_line_width(draw, self._merge_line_segments(candidate), letter_spacing)
+
+ if not current and candidate_width > max_width:
+ head, tail = self._split_token_to_fit(draw, token, max_width, letter_spacing)
+ if head is not None:
+ current.append(head)
+ lines.append(self._merge_line_segments(current))
+ current = []
+ idx += 1
+ if tail is not None and tail.get("text"):
+ tokens.insert(idx, tail)
+ continue
+
+ if candidate_width <= max_width or not current:
+ current.append(token)
+ idx += 1
+ continue
+
+ lines.append(self._merge_line_segments(current))
+ current = []
+
+ if token.get("text", "").isspace():
+ idx += 1
+
+ if current or not lines or ended_with_newline:
+ lines.append(self._merge_line_segments(current))
+
+ return lines
# ---------------- core drawing ----------------
- def _compute_layout(self, img_w, img_h, draw, text, font, stroke_width, padding,
+ def _compute_layout(self, img_w, img_h, draw, text, all_caps, font_name, stroke_width, padding,
h_align, v_align, x_shift, y_shift, line_spacing, letter_spacing, font_size, use_cache):
- try:
- current_font_id = (font.getname(), getattr(font, "path", None))
- except Exception:
- current_font_id = (None, None)
+ cache_key = (
+ img_w, img_h, text, bool(all_caps), font_name, int(font_size), int(stroke_width),
+ int(padding), float(line_spacing), float(letter_spacing)
+ )
- need_recompute = True
- if hasattr(self, "_cached") and self._cached is not None and use_cache:
- (*_, cached_letter_spacing, cached_stroke_width,
- cached_text, cached_font_id, cached_font_size, cached_padding, cached_line_spacing) = self._cached
- if (cached_letter_spacing == letter_spacing and
- cached_stroke_width == stroke_width and
- cached_text == text and
- cached_font_id == current_font_id and
- cached_font_size == font_size and
- cached_padding == padding and
- cached_line_spacing == line_spacing):
- need_recompute = False
+ need_recompute = not (hasattr(self, "_cached") and self._cached is not None and use_cache and self._cached.get("key") == cache_key)
if need_recompute:
- lines = self._wrap_lines(draw, text, font, img_w, padding, letter_spacing)
- widths, heights, tops = [], [], []
- lefts, rights_sp = [], []
- for ln in lines:
- l, t, r, b = draw.textbbox((0, 0), ln, font=font, stroke_width=stroke_width)
- w = (r - l)
- h = (b - t)
- extra = max(0, len(ln) - 1) * letter_spacing
- widths.append(w + extra)
- heights.append(h)
- tops.append(t)
- lefts.append(l)
- rights_sp.append(r + extra)
+ default_font = self._load_font(font_name, font_size)
+ lines = self._wrap_styled_lines(draw, text, all_caps, font_name, font_size, img_w - 2 * padding, letter_spacing)
+ widths, tops, heights = [], [], []
- min_left = min(lefts) if lefts else 0
- max_right = max(rights_sp) if rights_sp else 0
- block_w = max_right - min_left if rights_sp else 0
- block_h = sum(heights) + (len(heights) - 1) * line_spacing if heights else 0
- min_top = min(tops) if tops else 0
+ for line in lines:
+ widths.append(self._measure_line_width(draw, line, letter_spacing))
+ line_top, _line_bottom, line_height = self._compute_line_metrics(draw, line, stroke_width, default_font)
+ tops.append(line_top)
+ heights.append(line_height)
- self._cached = (
- lines, widths, heights, tops, min_top, min_left,
- block_w, block_h,
- letter_spacing, stroke_width,
- text, current_font_id, font_size, padding, line_spacing
- )
+ block_w = max(widths) if widths else 0
+ block_h = (sum(heights) + (len(heights) - 1) * line_spacing) if heights else 0
- (lines, widths, heights, tops, min_top, min_left,
- block_w, block_h, _cached_letter_spacing, _cached_stroke_width, *_) = self._cached
+ self._cached = {
+ "key": cache_key,
+ "lines": lines,
+ "widths": widths,
+ "tops": tops,
+ "heights": heights,
+ "block_w": block_w,
+ "block_h": block_h,
+ }
+
+ lines = self._cached["lines"]
+ widths = self._cached["widths"]
+ tops = self._cached["tops"]
+ heights = self._cached["heights"]
+ block_w = self._cached["block_w"]
+ block_h = self._cached["block_h"]
if h_align == "left":
x0 = padding
@@ -225,7 +506,7 @@ class TextOverlay:
x0 = int(round(x0 + x_shift))
visual_top_y = int(round(visual_top_y + y_shift))
- return lines, widths, heights, tops, min_top, min_left, block_w, block_h, x0, visual_top_y
+ return lines, widths, heights, tops, block_w, block_h, x0, visual_top_y
def draw_text(
self,
@@ -267,9 +548,6 @@ class TextOverlay:
loaded_font = self._load_font(font, font_size)
draw = ImageDraw.Draw(image, "RGBA")
- if all_caps:
- text = text.upper()
-
opacity_scale = max(0.0, min(1.0, float(opacity_scale)))
fill_alpha = max(0.0, min(1.0, float(fill_alpha) * opacity_scale))
stroke_alpha = max(0.0, min(1.0, float(stroke_alpha) * opacity_scale))
@@ -280,16 +558,13 @@ class TextOverlay:
if sw % 2 == 1 and sw > 0:
sw += 1
- (lines, widths, heights, tops, min_top, min_left, block_w, block_h,
+ (lines, widths, heights, tops, block_w, block_h,
x0, visual_top_y) = self._compute_layout(
- image.width, image.height, draw, text, loaded_font, sw,
+ image.width, image.height, draw, text, all_caps, font, sw,
padding, horizontal_alignment, vertical_alignment, x_shift, y_shift,
line_spacing, letter_spacing, font_size, use_cache
)
- first_line_baseline_y = visual_top_y - min_top
- x_draw = x0 - min_left
-
def _line_offset(i):
if font_alignment == "left":
return 0
@@ -298,6 +573,38 @@ class TextOverlay:
else:
return int(round(block_w - widths[i]))
+ def _positioned_segments(line_segments, x_start):
+ positioned = []
+ nonempty = [seg for seg in line_segments if seg.get("text")]
+ xx = x_start
+ for idx, seg in enumerate(nonempty):
+ seg_w = self._measure_text_advance(draw, seg["text"], seg["font"], letter_spacing)
+ positioned.append((seg, xx, seg_w))
+ xx += seg_w
+ if idx < len(nonempty) - 1:
+ xx += letter_spacing
+ return positioned
+
+ def _draw_segment_chars(draw_ctx, seg, start_x, baseline_y, color_rgba, stroke_rgba=None, stroke_width=0, dx_extra=0, dy_extra=0):
+ text_value = seg.get("text", "")
+ if not text_value:
+ return
+
+ xx = start_x
+ for ch_idx, ch in enumerate(text_value):
+ kwargs = {"font": seg["font"]}
+ if stroke_rgba is not None and stroke_width > 0:
+ kwargs["stroke_width"] = stroke_width
+ kwargs["stroke_fill"] = stroke_rgba
+
+ draw_ctx.text((xx + dx_extra, baseline_y + dy_extra), ch, fill=color_rgba, **kwargs)
+
+ char_w = draw.textlength(ch, font=seg["font"])
+ if ch_idx < len(text_value) - 1:
+ xx += char_w + letter_spacing
+ else:
+ xx += char_w
+
# Background (animated alpha)
if bg_enable and block_w > 0 and block_h > 0:
br, bgc, bb = self.hex_to_rgb(bg_color_hex)
@@ -312,6 +619,39 @@ class TextOverlay:
od.rectangle(rect, fill=(br, bgc, bb, ba))
image = Image.alpha_composite(image, overlay)
+ inline_bg_overlay = Image.new("RGBA", image.size, (0, 0, 0, 0))
+ inline_bg_draw = ImageDraw.Draw(inline_bg_overlay, "RGBA")
+
+ inline_bg_alpha = int(max(0.0, min(1.0, float(bg_alpha) * opacity_scale)) * 255)
+ inline_bg_pad_x = max(1, int(round(font_size * 0.10)))
+ inline_bg_pad_y = max(1, int(round(font_size * 0.06)))
+ inline_bg_radius = max(0, int(round(min(bg_radius, font_size * 0.25))))
+
+ yy_top = visual_top_y
+ for i, line in enumerate(lines):
+ baseline_y = yy_top - tops[i]
+ x_line = x0 + _line_offset(i)
+ positioned = _positioned_segments(line, x_line)
+ for seg, seg_x, _seg_w in positioned:
+ bg_hex = seg["style"].get("bg")
+ if not bg_hex:
+ continue
+ br, bgc, bb = self.hex_to_rgb(bg_hex, fallback=self.hex_to_rgb(bg_color_hex))
+ bbox = draw.textbbox((seg_x, baseline_y), seg["text"], font=seg["font"], stroke_width=sw)
+ rect = [
+ bbox[0] - inline_bg_pad_x,
+ bbox[1] - inline_bg_pad_y,
+ bbox[2] + inline_bg_pad_x,
+ bbox[3] + inline_bg_pad_y,
+ ]
+ try:
+ inline_bg_draw.rounded_rectangle(rect, radius=inline_bg_radius, fill=(br, bgc, bb, inline_bg_alpha))
+ except Exception:
+ inline_bg_draw.rectangle(rect, fill=(br, bgc, bb, inline_bg_alpha))
+ yy_top += int(round(heights[i] + line_spacing))
+
+ image = Image.alpha_composite(image, inline_bg_overlay)
+
# Shadow (animated alpha)
if shadow_enable and block_w > 0 and block_h > 0:
sh_r, sh_g, sh_b = self.hex_to_rgb(shadow_color_hex)
@@ -320,14 +660,21 @@ class TextOverlay:
overlay = Image.new("RGBA", image.size, (0, 0, 0, 0))
od = ImageDraw.Draw(overlay, "RGBA")
- yy = first_line_baseline_y
- for i, (ln, h) in enumerate(zip(lines, heights)):
- xx = x_draw + _line_offset(i)
- for ch in ln:
- od.text((xx + sdx, yy + sdy), ch, font=loaded_font,
- fill=(sh_r, sh_g, sh_b, sh_a))
- xx += draw.textlength(ch, font=loaded_font) + letter_spacing
- yy += int(round(h + line_spacing))
+ yy_top = visual_top_y
+ for i, line in enumerate(lines):
+ baseline_y = yy_top - tops[i]
+ x_line = x0 + _line_offset(i)
+ for seg, seg_x, _seg_w in _positioned_segments(line, x_line):
+ _draw_segment_chars(
+ od,
+ seg,
+ seg_x,
+ baseline_y,
+ (sh_r, sh_g, sh_b, sh_a),
+ dx_extra=sdx,
+ dy_extra=sdy,
+ )
+ yy_top += int(round(heights[i] + line_spacing))
image = Image.alpha_composite(image, overlay)
@@ -340,18 +687,35 @@ class TextOverlay:
overlay = Image.new("RGBA", image.size, (0, 0, 0, 0))
od = ImageDraw.Draw(overlay, "RGBA")
- yy = first_line_baseline_y
- for i, (ln, h) in enumerate(zip(lines, heights)):
- xx = x_draw + _line_offset(i)
- for ch in ln:
+ yy_top = visual_top_y
+ for i, line in enumerate(lines):
+ baseline_y = yy_top - tops[i]
+ x_line = x0 + _line_offset(i)
+
+ for seg, seg_x, _seg_w in _positioned_segments(line, x_line):
+ seg_r, seg_g, seg_b = self.hex_to_rgb(seg["style"].get("fill"), fallback=(fr, fg, fb))
+
if sw > 0 and sa > 0:
- od.text((xx, yy), ch, font=loaded_font,
- fill=(sr, sg, sb, sa),
- stroke_width=sw, stroke_fill=(sr, sg, sb, sa))
+ _draw_segment_chars(
+ od,
+ seg,
+ seg_x,
+ baseline_y,
+ (seg_r, seg_g, seg_b, sa),
+ stroke_rgba=(sr, sg, sb, sa),
+ stroke_width=sw,
+ )
+
if fa > 0:
- od.text((xx, yy), ch, font=loaded_font, fill=(fr, fg, fb, fa))
- xx += draw.textlength(ch, font=loaded_font) + letter_spacing
- yy += int(round(h + line_spacing))
+ _draw_segment_chars(
+ od,
+ seg,
+ seg_x,
+ baseline_y,
+ (seg_r, seg_g, seg_b, fa),
+ )
+
+ yy_top += int(round(heights[i] + line_spacing))
image = Image.alpha_composite(image, overlay)