diff --git a/README.md b/README.md index 476e422..73c06ba 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ Supported tags: * `
` for line breaks * `red text` * `highlighted text` +* `centered line block` +* `
right-aligned line block
` * `combined styling` Supported attributes on ``: @@ -70,6 +72,7 @@ Supported attributes on ``: | `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: Episode 01
Now Playing ``` +#### Alignment override inside a left-aligned node + +```html +Intro line
+Centered title
+Ending line +``` + +#### Right-aligned callout block + +```html +
Right callout
+``` + ### ✔️ 6. Animation System Supports: @@ -241,6 +259,12 @@ or rich text: Hello world
Second line ``` +You can also override line alignment for just part of the content: + +```html +Centered line +``` + ### **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 | `
` | Line break | | `Text` | Changes text color for that segment | | `Text` | Draws a background highlight behind that segment | +| `Text` | Centers the wrapped portion regardless of the node's `font_alignment` | +| `
Text
` | Right-aligns the wrapped portion regardless of the node's `font_alignment` | | `Text` | Combined inline styling | ### Rich Text Examples @@ -290,11 +316,18 @@ Normal bold italic subtitle ``` +```html +Top line
+Centered mid title
+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 diff --git a/nodes.py b/nodes.py index 0967c75..cce463f 100644 --- a/nodes.py +++ b/nodes.py @@ -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]))