feat: support inline alignment overrides in rich text

Parse align attributes on span/div tags and apply left, center, or right alignment to wrapped line blocks without changing the node-wide font_alignment.

Update the README with new supported tags, examples, and notes so users can understand how inline alignment works and when it starts a new aligned block.feat: support inline alignment overrides in rich text

Parse align attributes on span/div tags and apply left, center, or right alignment to wrapped line blocks without changing the node-wide font_alignment.

Update the README with new supported tags, examples, and notes so users can understand how inline alignment works and when it starts a new aligned block.
This commit is contained in:
scofano
2026-03-23 09:15:55 -03:00
parent ae7a0923f6
commit 3e9dcbb67f
2 changed files with 88 additions and 11 deletions
+33
View File
@@ -58,6 +58,8 @@ Supported tags:
* `<br>` for line breaks
* `<span color="#FF0000">red text</span>`
* `<span bg="#000000">highlighted text</span>`
* `<span align="center">centered line block</span>`
* `<div align="right">right-aligned line block</div>`
* `<span style="color:#00FF00; background-color:#222222">combined styling</span>`
Supported attributes on `<span>`:
@@ -70,6 +72,7 @@ Supported attributes on `<span>`:
| `bg` | Per-segment highlight/background color |
| `background` | Alias for background color |
| `background-color` | Alias for background color |
| `align` | Overrides line alignment for that styled portion (`left`, `center`, `right`) |
| `style` | Inline CSS-like support for `color` and `background-color` |
Examples:
@@ -85,6 +88,7 @@ Notes:
* 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.
* Inline alignment overrides the node's `font_alignment` for the wrapped portion only.
* 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.
@@ -130,6 +134,20 @@ Title: <b>Episode 01</b><br>
<span color="#ffffff" bg="#7a0018">Now Playing</span>
```
#### Alignment override inside a left-aligned node
```html
Intro line<br>
<span align="center"><b>Centered title</b></span><br>
Ending line
```
#### Right-aligned callout block
```html
<div align="right"><span bg="#222222" color="#ffffff">Right callout</span></div>
```
### ✔️ 6. Animation System
Supports:
@@ -241,6 +259,12 @@ or rich text:
Hello <b>world</b><br><span color="#00d0ff">Second line</span>
```
You can also override line alignment for just part of the content:
```html
<span align="center">Centered line</span>
```
### **Video Node: `Advanced Text Overlay – Video`**
Provide a path to a video → configure overlay → output is written to ComfyUI's output directory.
@@ -269,6 +293,8 @@ Provide a path to a video → configure overlay → output is written to ComfyUI
| `<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 align="center">Text</span>` | Centers the wrapped portion regardless of the node's `font_alignment` |
| `<div align="right">Text</div>` | Right-aligns the wrapped portion regardless of the node's `font_alignment` |
| `<span style="color:#fff; background-color:#000">Text</span>` | Combined inline styling |
### Rich Text Examples
@@ -290,11 +316,18 @@ Normal <b>bold</b> <i>italic</i>
<span style="color:#ffffff; background-color:#333333">subtitle</span>
```
```html
Top line<br>
<span align="center" color="#ffffff" bg="#444444"><b>Centered mid title</b></span><br>
Bottom line
```
### 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.
* Alignment overrides operate at the wrapped line/block level. If alignment changes mid-text, the renderer starts a new aligned line block for that portion.
* Whole-block background settings (`bg_enable`, `bg_padding`, `bg_color_hex`, etc.) still apply independently from inline segment highlights.
### Color & Stroke
+55 -11
View File
@@ -31,7 +31,20 @@ class InlineRichTextParser(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.runs = []
self._style_stack = [{"bold": False, "italic": False, "fill": None, "bg": None}]
self._style_stack = [{"bold": False, "italic": False, "fill": None, "bg": None, "align": None}]
def _normalize_align(self, value):
value = (value or "").strip().lower()
mapping = {
"left": "left",
"start": "left",
"center": "center",
"centre": "center",
"middle": "center",
"right": "right",
"end": "right",
}
return mapping.get(value)
def handle_starttag(self, tag, attrs):
tag = (tag or "").lower()
@@ -47,7 +60,9 @@ class InlineRichTextParser(HTMLParser):
new_style["bold"] = True
elif tag == "i":
new_style["italic"] = True
elif tag == "span":
elif tag in ("left", "center", "right"):
new_style["align"] = self._normalize_align(tag)
elif tag in ("span", "div", "p"):
span_style = self._parse_span_attrs(attrs)
for key, value in span_style.items():
if value is not None:
@@ -70,7 +85,7 @@ class InlineRichTextParser(HTMLParser):
self.runs.append({"text": data, "style": self._style_stack[-1].copy()})
def _parse_span_attrs(self, attrs):
parsed = {"fill": None, "bg": None}
parsed = {"fill": None, "bg": None, "align": None}
if attrs.get("color"):
parsed["fill"] = attrs.get("color")
@@ -83,6 +98,9 @@ class InlineRichTextParser(HTMLParser):
if attrs.get(key):
parsed["bg"] = attrs.get(key)
if attrs.get("align"):
parsed["align"] = self._normalize_align(attrs.get("align"))
style_text = attrs.get("style", "") or ""
for part in style_text.split(";"):
if ":" not in part:
@@ -92,6 +110,8 @@ class InlineRichTextParser(HTMLParser):
value = value.strip()
if key == "color":
parsed["fill"] = value
elif key == "text-align":
parsed["align"] = self._normalize_align(value)
elif key in ("background", "background-color"):
parsed["bg"] = value
@@ -199,6 +219,7 @@ class TextOverlay:
bool(style.get("italic")),
style.get("fill"),
style.get("bg"),
style.get("align"),
)
def _font_signature(self, font_obj):
@@ -234,7 +255,7 @@ class TextOverlay:
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}
default_style = {"bold": False, "italic": False, "fill": None, "bg": None, "align": None}
try:
parser.feed(normalized)
@@ -393,26 +414,41 @@ class TextOverlay:
tokens = self._tokenize_runs(runs, font_name, font_size)
if not tokens:
return [[]]
return ([[]], [None])
lines = []
line_aligns = []
current = []
current_align = None
idx = 0
ended_with_newline = False
max_width = max(1, int(round(max_width)))
while idx < len(tokens):
token = tokens[idx]
token_align = token.get("style", {}).get("align")
if token.get("newline"):
lines.append(self._merge_line_segments(current))
line_aligns.append(current_align)
current = []
current_align = None
ended_with_newline = True
idx += 1
continue
ended_with_newline = False
if current and token_align != current_align:
lines.append(self._merge_line_segments(current))
line_aligns.append(current_align)
current = []
current_align = None
continue
if not current:
current_align = token_align
if not current and token.get("text", "").isspace():
idx += 1
continue
@@ -425,7 +461,9 @@ class TextOverlay:
if head is not None:
current.append(head)
lines.append(self._merge_line_segments(current))
line_aligns.append(current_align)
current = []
current_align = None
idx += 1
if tail is not None and tail.get("text"):
tokens.insert(idx, tail)
@@ -437,15 +475,18 @@ class TextOverlay:
continue
lines.append(self._merge_line_segments(current))
line_aligns.append(current_align)
current = []
current_align = None
if token.get("text", "").isspace():
idx += 1
if current or not lines or ended_with_newline:
lines.append(self._merge_line_segments(current))
line_aligns.append(current_align)
return lines
return lines, line_aligns
# ---------------- core drawing ----------------
@@ -460,7 +501,7 @@ class TextOverlay:
if need_recompute:
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)
lines, line_aligns = self._wrap_styled_lines(draw, text, all_caps, font_name, font_size, img_w - 2 * padding, letter_spacing)
widths, tops, heights = [], [], []
for line in lines:
@@ -475,6 +516,7 @@ class TextOverlay:
self._cached = {
"key": cache_key,
"lines": lines,
"line_aligns": line_aligns,
"widths": widths,
"tops": tops,
"heights": heights,
@@ -483,6 +525,7 @@ class TextOverlay:
}
lines = self._cached["lines"]
line_aligns = self._cached["line_aligns"]
widths = self._cached["widths"]
tops = self._cached["tops"]
heights = self._cached["heights"]
@@ -506,7 +549,7 @@ class TextOverlay:
x0 = int(round(x0 + x_shift))
visual_top_y = int(round(visual_top_y + y_shift))
return lines, widths, heights, tops, block_w, block_h, x0, visual_top_y
return lines, line_aligns, widths, heights, tops, block_w, block_h, x0, visual_top_y
def draw_text(
self,
@@ -558,7 +601,7 @@ class TextOverlay:
if sw % 2 == 1 and sw > 0:
sw += 1
(lines, widths, heights, tops, block_w, block_h,
(lines, line_aligns, widths, heights, tops, block_w, block_h,
x0, visual_top_y) = self._compute_layout(
image.width, image.height, draw, text, all_caps, font, sw,
padding, horizontal_alignment, vertical_alignment, x_shift, y_shift,
@@ -566,9 +609,10 @@ class TextOverlay:
)
def _line_offset(i):
if font_alignment == "left":
line_alignment = line_aligns[i] or font_alignment
if line_alignment == "left":
return 0
elif font_alignment == "center":
elif line_alignment == "center":
return int(round((block_w - widths[i]) / 2))
else:
return int(round(block_w - widths[i]))