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:
@@ -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
|
||||
|
||||
@@ -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]))
|
||||
|
||||
Reference in New Issue
Block a user