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.
This commit is contained in:
@@ -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:
|
||||
|
||||
* `<b>bold</b>`
|
||||
* `<i>italic</i>`
|
||||
* `<br>` for line breaks
|
||||
* `<span color="#FF0000">red text</span>`
|
||||
* `<span bg="#000000">highlighted text</span>`
|
||||
* `<span style="color:#00FF00; background-color:#222222">combined styling</span>`
|
||||
|
||||
Supported attributes on `<span>`:
|
||||
|
||||
| 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 <b>bold</b> and <i>italic</i><br>
|
||||
<span color="#ff0000">red</span> and <span color="#00aaff" bg="#111111">blue on dark background</span>
|
||||
```
|
||||
|
||||
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 <b>bold</b>, <i>italic</i>, and <b><i>both</i></b>.
|
||||
```
|
||||
|
||||
#### Multi-color text
|
||||
|
||||
```html
|
||||
<span color="#ff5555">Red</span>
|
||||
<span color="#55ff55">Green</span>
|
||||
<span color="#5599ff">Blue</span>
|
||||
```
|
||||
|
||||
#### Highlighted words only
|
||||
|
||||
```html
|
||||
Normal text with <span bg="#6b1d1d">highlighted words</span> in the middle.
|
||||
```
|
||||
|
||||
#### Combined color + highlight + emphasis
|
||||
|
||||
```html
|
||||
<span color="#ffffff" bg="#004488"><b>Important label</b></span>
|
||||
```
|
||||
|
||||
#### Using inline style
|
||||
|
||||
```html
|
||||
<span style="color:#ffd700; background-color:#202020">Styled with inline attributes</span>
|
||||
```
|
||||
|
||||
#### Multiline rich text
|
||||
|
||||
```html
|
||||
Title: <b>Episode 01</b><br>
|
||||
<span color="#cccccc">Subtitle line</span><br>
|
||||
<span color="#ffffff" bg="#7a0018">Now Playing</span>
|
||||
```
|
||||
|
||||
### ✔️ 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 <b>world</b><br><span color="#00d0ff">Second line</span>
|
||||
```
|
||||
|
||||
### **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 `<b>`, `<i>`, `<br>`, and `<span color/bg>` |
|
||||
| `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 |
|
||||
| ------ | ------ |
|
||||
| `<b>Text</b>` | Bold text when a matching font variant is available |
|
||||
| `<i>Text</i>` | Italic text when a matching font variant is available |
|
||||
| `<br>` | Line break |
|
||||
| `<span color="#RRGGBB">Text</span>` | Changes text color for that segment |
|
||||
| `<span bg="#RRGGBB">Text</span>` | Draws a background highlight behind that segment |
|
||||
| `<span style="color:#fff; background-color:#000">Text</span>` | Combined inline styling |
|
||||
|
||||
### Rich Text Examples
|
||||
|
||||
```html
|
||||
Normal <b>bold</b> <i>italic</i>
|
||||
```
|
||||
|
||||
```html
|
||||
<span color="#FFD700">Gold</span> <span color="#00FFFF">Cyan</span>
|
||||
```
|
||||
|
||||
```html
|
||||
<span bg="#8B0000">Red highlight</span> and <span color="#FFFFFF" bg="#004488">white on blue</span>
|
||||
```
|
||||
|
||||
```html
|
||||
<span color="#FFD700"><b>Golden title</b></span><br>
|
||||
<span style="color:#ffffff; background-color:#333333">subtitle</span>
|
||||
```
|
||||
|
||||
### 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 |
|
||||
|
||||
+83
-1
@@ -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)
|
||||
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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user