diff --git a/.github/workflows/publish_action.yml b/.github/workflows/publish_action.yml
new file mode 100644
index 0000000..4081728
--- /dev/null
+++ b/.github/workflows/publish_action.yml
@@ -0,0 +1,27 @@
+name: 📦 Publish to Comfy registry
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - main
+ paths:
+ - "pyproject.toml"
+
+permissions:
+ issues: write
+
+jobs:
+ publish-node:
+ name: Publish Custom Node to registry
+ runs-on: ubuntu-latest
+ # Just to be nice - to prevent unwanted auto-runs on forks made solely for a PR:
+ if: ${{ github.repository_owner == 'Lex-DRL' }}
+ steps:
+ - name: ♻️ Check out code
+ uses: actions/checkout@v4
+ - name: 📦 Publish Custom Node
+ uses: Comfy-Org/publish-node-action@main
+ with:
+ # Add your own personal access token to your Github Repository secrets and reference it here.
+ personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
+ # personal_access_token: {% raw %}${{ secrets.REGISTRY_ACCESS_TOKEN }}{% endraw %}
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..8d0b238
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,11 @@
+# v0.0.1
+
+MVP nodes:
+- `Add String to Format-Dict`
+- `Format-Dict from Text`
+- `Format-Dict Preview`
+- `String Formatter`
+
+⚠️ Active `TODO:`
+- Recursive formatting.
+- Append non-string to Format-Dict.
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
index 0000000..0927556
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,157 @@
+### GNU LESSER GENERAL PUBLIC LICENSE
+
+Version 3, 29 June 2007
+
+Copyright (C) 2007 Free Software Foundation, Inc.
+
+
+Everyone is permitted to copy and distribute verbatim copies of this
+license document, but changing it is not allowed.
+
+This version of the GNU Lesser General Public License incorporates the
+terms and conditions of version 3 of the GNU General Public License,
+supplemented by the additional permissions listed below.
+
+#### 0. Additional Definitions.
+
+As used herein, "this License" refers to version 3 of the GNU Lesser
+General Public License, and the "GNU GPL" refers to version 3 of the
+GNU General Public License.
+
+"The Library" refers to a covered work governed by this License, other
+than an Application or a Combined Work as defined below.
+
+An "Application" is any work that makes use of an interface provided
+by the Library, but which is not otherwise based on the Library.
+Defining a subclass of a class defined by the Library is deemed a mode
+of using an interface provided by the Library.
+
+A "Combined Work" is a work produced by combining or linking an
+Application with the Library. The particular version of the Library
+with which the Combined Work was made is also called the "Linked
+Version".
+
+The "Minimal Corresponding Source" for a Combined Work means the
+Corresponding Source for the Combined Work, excluding any source code
+for portions of the Combined Work that, considered in isolation, are
+based on the Application, and not on the Linked Version.
+
+The "Corresponding Application Code" for a Combined Work means the
+object code and/or source code for the Application, including any data
+and utility programs needed for reproducing the Combined Work from the
+Application, but excluding the System Libraries of the Combined Work.
+
+#### 1. Exception to Section 3 of the GNU GPL.
+
+You may convey a covered work under sections 3 and 4 of this License
+without being bound by section 3 of the GNU GPL.
+
+#### 2. Conveying Modified Versions.
+
+If you modify a copy of the Library, and, in your modifications, a
+facility refers to a function or data to be supplied by an Application
+that uses the facility (other than as an argument passed when the
+facility is invoked), then you may convey a copy of the modified
+version:
+
+- a) under this License, provided that you make a good faith effort
+ to ensure that, in the event an Application does not supply the
+ function or data, the facility still operates, and performs
+ whatever part of its purpose remains meaningful, or
+- b) under the GNU GPL, with none of the additional permissions of
+ this License applicable to that copy.
+
+#### 3. Object Code Incorporating Material from Library Header Files.
+
+The object code form of an Application may incorporate material from a
+header file that is part of the Library. You may convey such object
+code under terms of your choice, provided that, if the incorporated
+material is not limited to numerical parameters, data structure
+layouts and accessors, or small macros, inline functions and templates
+(ten or fewer lines in length), you do both of the following:
+
+- a) Give prominent notice with each copy of the object code that
+ the Library is used in it and that the Library and its use are
+ covered by this License.
+- b) Accompany the object code with a copy of the GNU GPL and this
+ license document.
+
+#### 4. Combined Works.
+
+You may convey a Combined Work under terms of your choice that, taken
+together, effectively do not restrict modification of the portions of
+the Library contained in the Combined Work and reverse engineering for
+debugging such modifications, if you also do each of the following:
+
+- a) Give prominent notice with each copy of the Combined Work that
+ the Library is used in it and that the Library and its use are
+ covered by this License.
+- b) Accompany the Combined Work with a copy of the GNU GPL and this
+ license document.
+- c) For a Combined Work that displays copyright notices during
+ execution, include the copyright notice for the Library among
+ these notices, as well as a reference directing the user to the
+ copies of the GNU GPL and this license document.
+- d) Do one of the following:
+ - 0) Convey the Minimal Corresponding Source under the terms of
+ this License, and the Corresponding Application Code in a form
+ suitable for, and under terms that permit, the user to
+ recombine or relink the Application with a modified version of
+ the Linked Version to produce a modified Combined Work, in the
+ manner specified by section 6 of the GNU GPL for conveying
+ Corresponding Source.
+ - 1) Use a suitable shared library mechanism for linking with
+ the Library. A suitable mechanism is one that (a) uses at run
+ time a copy of the Library already present on the user's
+ computer system, and (b) will operate properly with a modified
+ version of the Library that is interface-compatible with the
+ Linked Version.
+- e) Provide Installation Information, but only if you would
+ otherwise be required to provide such information under section 6
+ of the GNU GPL, and only to the extent that such information is
+ necessary to install and execute a modified version of the
+ Combined Work produced by recombining or relinking the Application
+ with a modified version of the Linked Version. (If you use option
+ 4d0, the Installation Information must accompany the Minimal
+ Corresponding Source and Corresponding Application Code. If you
+ use option 4d1, you must provide the Installation Information in
+ the manner specified by section 6 of the GNU GPL for conveying
+ Corresponding Source.)
+
+#### 5. Combined Libraries.
+
+You may place library facilities that are a work based on the Library
+side by side in a single library together with other library
+facilities that are not Applications and are not covered by this
+License, and convey such a combined library under terms of your
+choice, if you do both of the following:
+
+- a) Accompany the combined library with a copy of the same work
+ based on the Library, uncombined with any other library
+ facilities, conveyed under the terms of this License.
+- b) Give prominent notice with the combined library that part of it
+ is a work based on the Library, and explaining where to find the
+ accompanying uncombined form of the same work.
+
+#### 6. Revised Versions of the GNU Lesser General Public License.
+
+The Free Software Foundation may publish revised and/or new versions
+of the GNU Lesser General Public License from time to time. Such new
+versions will be similar in spirit to the present version, but may
+differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Library
+as you received it specifies that a certain numbered version of the
+GNU Lesser General Public License "or any later version" applies to
+it, you have the option of following the terms and conditions either
+of that published version or of any later version published by the
+Free Software Foundation. If the Library as you received it does not
+specify a version number of the GNU Lesser General Public License, you
+may choose any version of the GNU Lesser General Public License ever
+published by the Free Software Foundation.
+
+If the Library as you received it specifies that a proxy can decide
+whether future versions of the GNU Lesser General Public License shall
+apply, that proxy's public statement of acceptance of any version is
+permanent authorization for you to choose that version for the
+Library.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..f5263b7
--- /dev/null
+++ b/README.md
@@ -0,0 +1,27 @@
+> "Do one thing and do it well." _— Peter H. Salus / Doug McIlroy, [core Unix principle](https://en.wikipedia.org/wiki/Unix_philosophy)_
+
+> "Simple is better than complex." _— Zen of Python_
+
+# `String Constructor` (Text-Formatting) nodes
+
+There's already a plenty of string-formatting nodes for ComfyUI. But this node pack takes a different approach:
+- First, you prepare a dictionary of named sub-strings (chunks of text).
+- Then, right before `CLIP Text Encode`, you construct a final prompt from these parts, using python's [string formatting syntax](https://docs.python.org/3/library/string.html#format-examples)... **Don't panic!** You don't need to be a programmer - just look below for a quick introduction.
+- Unlike many other _(giant uber-mega)_ node packs, this one strictly adheres to the modular philosophy: it's minimal and self-sufficient.
+
+[🔄 Updates ChangeLog](CHANGELOG.md)
+
+
+
+## Syntax
+
+For those who are unfamiliar with python's string formatting, the rules are very simple:
+- You need to name (set tags/**keys** for) your sub-strings the same way python variables are named: only ASCII letters, digits and underscore are allowed + the name can't start with a digit. So:
+ - ✅ `valid_name`, `other_valid_name`, `YetAnother_ValidName`, `name3`.
+ - ❌ `wrong name with spaces`, `wrong-name.with:punctuation`, `3name`.
+- Then, you simply put your `{substring_name}` inside curly braces (no spaces between) in the formatting template - and voila! This pattern will be replaced with the actual sub-string.
+
+> [!NOTE]
+> For advanced users:
+> Internally, a built-in `str.format()` is called with keyword arguments from `str_dict`, which is literally just a dict... though, an immutable one.
+> So any formatting patterns are available (like `{float_value:.3f}`). However, currently there are no nodes to append non-string values (planned, TODO).
diff --git a/__init__.py b/__init__.py
new file mode 100644
index 0000000..83000be
--- /dev/null
+++ b/__init__.py
@@ -0,0 +1,25 @@
+# encoding: utf-8
+"""
+"""
+
+import typing as _t
+
+from .node_dict_add_string import StringConstructorDictAddString
+from .node_dict_from_text import StringConstructorDictFromText
+from .node_dict_preview import StringConstructorDictPreview
+from .node_formatter import StringConstructorFormatter
+
+NODE_CLASS_MAPPINGS: _t.Dict[str, type] = {
+ 'StringConstructorDictAddString': StringConstructorDictAddString,
+ 'StringConstructorDictFromText': StringConstructorDictFromText,
+ 'StringConstructorDictPreview': StringConstructorDictPreview,
+ 'StringConstructorFormatter': StringConstructorFormatter,
+}
+NODE_DISPLAY_NAME_MAPPINGS: _t.Dict[str, str] = {
+ 'StringConstructorDictAddString': "Add String to Format-Dict",
+ 'StringConstructorDictFromText': "Format-Dict from Text",
+ 'StringConstructorDictPreview': "Preview Format-Dict",
+ 'StringConstructorFormatter': "String Formatter",
+}
+
+__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
diff --git a/_meta.py b/_meta.py
new file mode 100644
index 0000000..f716a0e
--- /dev/null
+++ b/_meta.py
@@ -0,0 +1,6 @@
+# encoding: utf-8
+"""
+Metadata-related module.
+"""
+
+category: str = "String Constructor"
diff --git a/docstring_formatter.py b/docstring_formatter.py
new file mode 100644
index 0000000..b8a7ede
--- /dev/null
+++ b/docstring_formatter.py
@@ -0,0 +1,146 @@
+# encoding: utf-8
+"""
+"""
+
+import typing as _t
+
+from inspect import getdoc as _getdoc
+import re as _re
+
+
+_re_indent_match = _re.compile("(\t*)( +)(\t*)(.*?)$").match
+_re_tab_indent_match = _re.compile("(\t+)(.*?)$").match
+_re_list_line_match = _re.compile(
+ "(\s*)("
+ "[-*•]+"
+ "|"
+ "[a-zA-Z]\s*[.)]"
+ "|"
+ "[0-9+]\s*[.)]"
+ ")\s+"
+).match
+
+
+def _recover_tab_indents(line: str, tab_size: int):
+ """Turn indenting spaces back to tabs using regexp. Half-tab indents are rounded."""
+ assert bool(line) and isinstance(line, str)
+
+ n_tabs = 0.0
+
+ match = _re_indent_match(line)
+ while match:
+ pre_tabs, spaces, post_tabs, line = match.groups()
+ n_tabs_from_spaces = float(len(spaces)) / tab_size + 0.00001
+ n_post_tabs = len(post_tabs)
+ if n_post_tabs > 0:
+ # There are tabs after spaces. Don't preserve the fractional spaces-indent, truncate it:
+ n_tabs_from_spaces = int(n_tabs_from_spaces)
+ n_tabs += len(pre_tabs) + n_tabs_from_spaces + n_post_tabs
+ match = _re_indent_match(line)
+
+ if n_tabs < 0.5:
+ return line
+
+ tabs_prefix = '\t' * int(n_tabs + 0.50001)
+ return f"{tabs_prefix}{line}"
+
+
+def _join_paragraph_and_format_tabs(paragraph: _t.List[str], tab_size: int):
+ """
+ Given "continuous" paragraph (i.e., with no empty newlines between chunks), recover tabs for each chunk
+ and join them together into a single actual line.
+ Works as a generator to account for blocks with different indents - to make each its own line.
+ """
+ pending_indent = 0
+ pending_chunks: _t.List[str] = list()
+
+ def join_pending_chunks() -> str:
+ return "{}{}".format('\t' * pending_indent, ' '.join(pending_chunks))
+
+ for chunk in paragraph:
+ chunk = _recover_tab_indents(chunk, tab_size)
+
+ cur_indent = 0
+ match = _re_tab_indent_match(chunk)
+ if match:
+ tab_indent, chunk = match.groups() # We've detected indent. Now, get rid of it.
+ cur_indent = len(tab_indent)
+
+ match_list_line = _re_list_line_match(chunk)
+ # In case of a bulleted/numbered list, we'll need to start a new block, too.
+ if cur_indent == pending_indent and not match_list_line:
+ pending_chunks.append(chunk)
+ continue
+
+ # Indent mismatch or a list line:
+ # we're either ended one block or entered another. Either way, the previous block ends.
+ if pending_chunks:
+ yield join_pending_chunks()
+ pending_chunks = list()
+ assert not pending_chunks
+ pending_chunks.append(chunk)
+ pending_indent = cur_indent
+
+ if pending_chunks:
+ yield join_pending_chunks()
+
+
+def _formatted_paragraphs_gen(doc: str, tab_size: int):
+ """
+ Generator, which splits docstring into lines and transforms them into an actual printable output:
+ - From each bulk of empty lines, the first one is skipped...
+ - ... thus, non-empty lines are joined into continuous paragraphs.
+ - Recover tabs in the beginning oh lines (``inspect.cleandoc()`` converts them into spaces).
+ """
+ if not doc:
+ return
+ doc = str(doc)
+ if not doc.strip():
+ return
+
+ tab_size = max(int(tab_size), 1)
+
+ cur_paragraph: _t.List[str] = list()
+
+ for line in doc.splitlines():
+ line: str = line.rstrip()
+ if line:
+ cur_paragraph.append(line)
+ continue
+
+ assert not line
+ if cur_paragraph:
+ for block in _join_paragraph_and_format_tabs(cur_paragraph, tab_size):
+ yield block
+ cur_paragraph = list()
+ # Just skip the current empty line entirely - do nothing with it.
+ continue
+
+ # We're in a chain of empty lines, and we've already skipped the first one. Preserve the remaining ones:
+ yield ''
+
+ # Return the last paragraph post-loop:
+ if cur_paragraph:
+ for block in _join_paragraph_and_format_tabs(cur_paragraph, tab_size):
+ yield block
+
+
+def format_docstring(doc: str, tab_size: int = 8) -> str:
+ """
+ Turn a pre-cleaned-up docstring (with tabs as spaces and newlines mid-sentence)
+ into an actually printable output:
+ - mid-paragraph new lines are replaced with spaces...
+ - ... while still keeping indented blocks separate.
+
+ Remember to pass a pre-cleaned-up docstring - i.e.: ``format_docstring(inspect.cleandoc(__doc__))``
+ """
+ return '\n'.join(_formatted_paragraphs_gen(doc, tab_size))
+
+
+def format_object_docstring(_obj, tab_size: int = 8) -> str:
+ """Find the object's docstring and format it with ``format_docstring()``"""
+ doc = _getdoc(_obj)
+ if not doc:
+ return ''
+ # noinspection PyArgumentList
+ return format_docstring(doc, tab_size=tab_size)
diff --git a/enums.py b/enums.py
new file mode 100644
index 0000000..8bbbc83
--- /dev/null
+++ b/enums.py
@@ -0,0 +1,38 @@
+# encoding: utf-8
+"""
+Internal enums.
+"""
+
+import typing as _t
+
+try:
+ from enum import StrEnum as _StrEnum
+except ImportError:
+ from comfy.comfy_types.node_typing import StrEnum as _StrEnum
+
+
+T = _t.TypeVar('T')
+
+
+class __BaseEnum(_StrEnum):
+ @classmethod
+ def all_values(cls) -> _t.Tuple[str, ...]:
+ return tuple(x.value for x in cls)
+
+
+class DataTypes(__BaseEnum):
+ """Additional data-types defined byt the node pack."""
+ STR_DICT = 'STR_DICT'
+
+ @staticmethod
+ def __custom_input_type_dict(_dict_args: _t.Tuple[_t.Dict[str, T], ...], kwargs: _t.Dict[str, T]) -> _t.Dict[str, T]:
+ res_dict: _t.Dict[str, _t.Any] = {"forceInput": True}
+ for d in _dict_args:
+ if d is not None:
+ res_dict.update(d)
+ res_dict.update(kwargs)
+ return res_dict
+
+ @classmethod
+ def input_str_dict(cls, *_dicts: _t.Dict[str, T], **kwargs: T) -> _t.Tuple[_t.Union['DataTypes', str], _t.Dict[str, T]]:
+ return cls.STR_DICT, cls.__custom_input_type_dict(_dicts, kwargs)
diff --git a/funcs_common.py b/funcs_common.py
new file mode 100644
index 0000000..c05db3e
--- /dev/null
+++ b/funcs_common.py
@@ -0,0 +1,81 @@
+# encoding: utf-8
+"""
+Code shared between various `funcs_*` modules.
+"""
+
+import typing as _t
+
+import re as _re
+
+from server import PromptServer as _PromptServer
+
+from .enums import T as _T
+
+
+def _show_text_on_node(text: str = None, unique_id: str = None):
+ if not text:
+ # TODO: Planned for the future - currently, there's no point removing the text since it's box is shown anyway
+ # An odd workaround since `send_progress_text()` doesn't want to update text when '' passed
+ text = ''
+ # print(f"{unique_id} text: {text!r}")
+
+ # Snatched from: https://github.com/comfyanonymous/ComfyUI/blob/27870ec3c30e56be9707d89a120eb7f0e2836be1/comfy_extras/nodes_images.py#L581-L582
+ _PromptServer.instance.send_progress_text(text, unique_id)
+
+
+_re_valid_key_match = _re.compile("[a-zA-Z_][a-zA-Z_0-9]*$").match
+_re_starting_digits_match = _re.compile("[0-9]+").match
+
+
+def _validate_key(key: str, errors_dict: _t.Dict[_t.Any, str]) -> _t.Union[str, None]:
+ """Verify that the given string can be a key. Otherwise, append a message to `errors_dict`."""
+ if not isinstance(key, str):
+ errors_dict[key] = f"Not a valid key (string name): {key!r}"
+ return None
+ key = str(key).strip()
+ if not key:
+ errors_dict[key] = f"Key (string name) can't be empty. Got: {key!r}"
+ return None
+ if not _re_valid_key_match(key):
+ if _re_starting_digits_match(key):
+ errors_dict[key] = f"Key (string name) can't start with a digit. Got: {key!r}"
+ else:
+ errors_dict[key] = f"Key (string name) must contain only numbers, latin letters and underscores. Got: {key!r}"
+ return None
+ return key
+
+
+def _raise_from_errors_dict(
+ errors_dict: _t.Dict[_t.Any, str],
+ single_error_format: str = '{}',
+ multi_errors_format: str = "Invalid keys (string names):\n{}",
+):
+ if not errors_dict:
+ return
+
+ if len(errors_dict) == 1:
+ msg = single_error_format.format(next(iter(errors_dict.values())))
+ else:
+ msg = multi_errors_format.format('\n'.join(repr(x) for x in errors_dict.keys()))
+ raise KeyError(msg)
+
+
+def _verify_input_dict(input_dict: _t.Dict[str, _T] = None) -> _t.Dict[str, _T]:
+ """
+ Verify input dict to have only valid keys. Raises errors if invalid ones found.
+ Always returns a new dict instance (shallow copy if non-empty dict passed).
+ """
+ if input_dict is None:
+ return dict()
+ if not isinstance(input_dict, dict):
+ raise TypeError(f"Input-dict isn't a dict. Got: {input_dict!r}")
+
+ errors_dict: _t.Dict[_t.Any, str] = dict()
+ for key in input_dict.keys():
+ _validate_key(key, errors_dict)
+
+ _raise_from_errors_dict(
+ errors_dict,
+ "Invalid input-dict:\n{}", "Invalid keys (string names) in input-dict:\n{}"
+ )
+ return dict(input_dict)
diff --git a/funcs_dict_add_string.py b/funcs_dict_add_string.py
new file mode 100644
index 0000000..da61249
--- /dev/null
+++ b/funcs_dict_add_string.py
@@ -0,0 +1,29 @@
+# encoding: utf-8
+"""
+The actual code (MVC pattern) for `StringConstructorDictString` node.
+"""
+
+import typing as _t
+
+from frozendict import deepfreeze as _deepfreeze
+
+from .enums import T as _T
+from .funcs_common import _raise_from_errors_dict, _validate_key, _verify_input_dict
+
+def set_dict_string(
+ name: str, value: str, strip_lines=True, in_dict: _t.Dict[str, _T] = None,
+ # unique_id: str = None,
+):
+ """Update/append a string to the dict."""
+ out_dict: _t.Dict[str, _T] = _verify_input_dict(in_dict)
+ errors_dict: _t.Dict[_t.Any, str] = dict()
+ name = _validate_key(name, errors_dict)
+ _raise_from_errors_dict(errors_dict)
+ value = str(value)
+ if strip_lines:
+ value = '\n'.join(
+ x.strip() for x in value.splitlines()
+ )
+ new_dict: _t.Dict[str, str] = {name: value}
+ out_dict.update(new_dict)
+ return (_deepfreeze(out_dict),)
diff --git a/funcs_dict_from_text.py b/funcs_dict_from_text.py
new file mode 100644
index 0000000..0a22aba
--- /dev/null
+++ b/funcs_dict_from_text.py
@@ -0,0 +1,81 @@
+# encoding: utf-8
+"""
+The actual code (MVC pattern) for `StringConstructorDictFromText` node.
+"""
+
+import typing as _t
+
+from frozendict import frozendict as _frozendict, deepfreeze as _deepfreeze
+
+from .enums import T as _T
+from .funcs_common import _show_text_on_node, _raise_from_errors_dict, _validate_key, _verify_input_dict
+
+
+def _return_line_raw(line_raw: str, line_stripped:str) -> str:
+ return line_raw
+
+
+def _return_line_stripped(line_raw: str, line_stripped:str) -> str:
+ return line_stripped
+
+
+def _parsed_kv_pairs_gen(multiline_str: str, strip_lines=True):
+ """
+ Given a multiline string, extract keywords (first non-empty line in each chunk)
+ and their substrings (all the following non-empty lines).
+ """
+ if not multiline_str:
+ return
+ multiline_str = str(multiline_str)
+ if not multiline_str.strip():
+ return
+
+ appended_line_f = _return_line_stripped if strip_lines else _return_line_raw
+ cur_chunk: _t.List[str] = list()
+
+ def dump_chunk():
+ cur_chunk_iter = iter(cur_chunk) # It's more backwards-compatible than `a, *b = x`
+ chunk_key = next(cur_chunk_iter)
+ cur_chunk_lines = list(cur_chunk_iter)
+ # If there are no actual lines, join() would return an empty string:
+ return chunk_key.strip(), '\n'.join(cur_chunk_lines)
+
+ for line in multiline_str.splitlines():
+ line_stripped: str = line.strip()
+ if line_stripped:
+ cur_chunk.append(appended_line_f(line, line_stripped))
+ continue
+
+ assert not line_stripped
+ if cur_chunk:
+ yield dump_chunk()
+ cur_chunk = list()
+
+ # We simply skip all the empty lines entirely
+
+ if cur_chunk:
+ yield dump_chunk()
+
+
+def parse_dict_from_text(
+ multiline_str: str, strip_lines=True,
+ show: bool = True, in_dict: _t.Dict[str, _T] = None, unique_id: str = None
+) -> _t.Tuple[_t.Dict[str, _T]]:
+ out_dict: _t.Dict[str, _T] = _verify_input_dict(in_dict)
+
+ errors_dict: _t.Dict[_t.Any, str] = dict()
+ new_dict: _t.Dict[str, str] = {
+ _k: _v for _k, _v in (
+ (_validate_key(k, errors_dict), v)
+ for k, v in _parsed_kv_pairs_gen(multiline_str, strip_lines=strip_lines)
+ ) if _k is not None
+ }
+ _raise_from_errors_dict(errors_dict)
+
+ out_dict.update(new_dict)
+
+ if show and unique_id:
+ status_text = ','.join(new_dict.keys()) if new_dict else ''
+ _show_text_on_node(status_text, unique_id)
+
+ return (_deepfreeze(out_dict), )
diff --git a/funcs_dict_preview.py b/funcs_dict_preview.py
new file mode 100644
index 0000000..0285211
--- /dev/null
+++ b/funcs_dict_preview.py
@@ -0,0 +1,47 @@
+# encoding: utf-8
+"""
+The actual code (MVC pattern) for `StringConstructorDictPreview` node.
+"""
+
+import typing as _t
+
+from pprint import pformat as _pformat
+
+from .funcs_common import _show_text_on_node, _verify_input_dict
+
+
+def _to_regular_dict_recursive_copy(input_dict: dict = None) -> dict:
+ """
+ To simplify preview, turns frozen dicts back to regular ones.
+ Copies the dict in process and does so recursively, but not truly deep: if value is another dict,
+ it will be processed, too. But dicts within other objects (like lists) won't be found.
+ """
+ if not input_dict:
+ return dict()
+ return {
+ k: (_to_regular_dict_recursive_copy(v) if isinstance(v, dict) else v)
+ for k, v in input_dict.items()
+ }
+
+
+def _preview_single_entry(key: str, value) -> str:
+ if isinstance(value, str):
+ return f"{key}:\n{value}"
+ value_repr = _pformat(value)
+ return f"{key}:\n{value_repr}"
+
+
+def _preview_message(input_dict: dict = None):
+ input_dict = _to_regular_dict_recursive_copy(_verify_input_dict(input_dict))
+ parts: _t.List[str] = [
+ _preview_single_entry(k, v) for k, v in input_dict.items()
+ ]
+ return '\n\n'.join(parts)
+
+
+def preview_str_dict(input_dict: dict, unique_id: str = None):
+ if not unique_id:
+ return (input_dict, )
+ status_text = _preview_message(input_dict)
+ _show_text_on_node(status_text, unique_id)
+ return (input_dict, )
diff --git a/funcs_formatter.py b/funcs_formatter.py
new file mode 100644
index 0000000..433c635
--- /dev/null
+++ b/funcs_formatter.py
@@ -0,0 +1,24 @@
+# encoding: utf-8
+"""
+The actual code (MVC pattern) for `StringConstructorFormatter` node.
+"""
+
+import typing as _t
+
+from .enums import T as _T
+from .funcs_common import _show_text_on_node, _verify_input_dict
+
+
+def formatter(template: str, format_dict: _t.Dict[str, _T] = None, show: bool = True, unique_id: str = None) -> _t.Tuple[str]:
+ format_dict = _verify_input_dict(format_dict)
+ if not isinstance(template, str):
+ raise TypeError(f"Not a string: {template!r}")
+ out_text = template.format_map(format_dict)
+ print(template)
+ print(format_dict)
+ print(out_text)
+
+ if show and unique_id:
+ _show_text_on_node(out_text, unique_id)
+
+ return (out_text, )
diff --git a/img/screenshot1.png b/img/screenshot1.png
new file mode 100644
index 0000000..169d12e
Binary files /dev/null and b/img/screenshot1.png differ
diff --git a/node_dict_add_string.py b/node_dict_add_string.py
new file mode 100644
index 0000000..189c7ff
--- /dev/null
+++ b/node_dict_add_string.py
@@ -0,0 +1,65 @@
+# encoding: utf-8
+"""
+"""
+
+import typing as _t
+
+from inspect import cleandoc as _cleandoc
+
+from frozendict import deepfreeze as _deepfreeze
+
+from comfy.comfy_types.node_typing import IO as _IO
+
+from . import _meta
+from .docstring_formatter import format_docstring as _format_docstring
+from .enums import DataTypes as _DataTypes
+from .funcs_dict_add_string import set_dict_string as _set_dict_string
+
+
+# A tiny optimization by reusing the same immutable dict:
+_input_types = _deepfreeze({
+ 'required': {
+ 'name': (_IO.STRING, {'tooltip': (
+ "Name of the substring inserted into the dict. "
+ "It must comprise only of latin letters, digits and underscores + it can't start with a digit."
+ )}),
+ 'cleanup': (_IO.BOOLEAN, {'default': True, 'label_on': 'extra spaces', 'label_off': 'no', 'tooltip': (
+ "When enabled, each line in the sub-string is stripped from any spaces at its start and end."
+ )}),
+ 'string': (_IO.STRING, {'multiline': True, 'tooltip': "The actual sub-string to set into the dict."}),
+ },
+ 'optional': {
+ 'str_dict': _DataTypes.input_str_dict(tooltip=(
+ "The dictionary to take named sub-strings from. It could be left unconnected, if the pattern doesn't reference "
+ "any sub-strings - then, this node acts exactly the same as a regular string-primitive node."
+ )),
+ },
+ # 'hidden': {
+ # 'unique_id': 'UNIQUE_ID', # used for text display at the bottom of the node
+ # },
+})
+
+
+class StringConstructorDictAddString:
+ """Add/update a string to the Format-Dict."""
+ NODE_NAME = 'StringConstructorDictAddString'
+ CATEGORY = _meta.category
+ DESCRIPTION = _format_docstring(_cleandoc(__doc__))
+
+ OUTPUT_NODE = True # Just to show the status message even if not connected to anything
+
+ FUNCTION = 'main'
+ RETURN_TYPES = (_DataTypes.STR_DICT, )
+ RETURN_NAMES = (_DataTypes.STR_DICT.lower(), )
+ # OUTPUT_TOOLTIPS = tuple()
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return _input_types
+
+ def main(
+ self, name: str, cleanup: bool, string: str,
+ str_dict: _t.Dict[str, _t.Any] = None,
+ # unique_id: str = None,
+ ):
+ return _set_dict_string(name, string, strip_lines=cleanup, in_dict=str_dict)
diff --git a/node_dict_from_text.py b/node_dict_from_text.py
new file mode 100644
index 0000000..b35bab7
--- /dev/null
+++ b/node_dict_from_text.py
@@ -0,0 +1,69 @@
+# encoding: utf-8
+"""
+"""
+
+import typing as _t
+
+from inspect import cleandoc as _cleandoc
+
+from frozendict import frozendict as _frozendict, deepfreeze as _deepfreeze
+
+from comfy.comfy_types.node_typing import IO as _IO
+
+from . import _meta
+from .docstring_formatter import format_docstring as _format_docstring
+from .enums import DataTypes as _DataTypes
+from .funcs_dict_from_text import parse_dict_from_text as _parse_dict_from_text
+
+
+# A tiny optimization by reusing the same immutable dict:
+_input_types = _deepfreeze({
+ 'required': {
+ 'cleanup': (_IO.BOOLEAN, {'default': True, 'label_on': 'extra spaces', 'label_off': 'no', 'tooltip': (
+ "When enabled, each line in each sub-string is stripped from any spaces at its start and end."
+ )}),
+ 'strings': (_IO.STRING, {'multiline': True, 'tooltip': (
+ "Sub-string names followed by their text. Different sub-string chunks are separated by empty lines. Example:\n\n"
+ "char1_short\n1boy, blond, short hair\n\n"
+ "char1_long\n1boy, smiling, blue eyes, blond, short hair,\nsitting on a motorcycle, wearing a leather jacket"
+ )}),
+ 'show_status': (_IO.BOOLEAN, {'default': False, 'label_on': 'found names', 'label_off': 'no', 'tooltip': (
+ "Show detected string names on the node itself?"
+ )}),
+ },
+ 'optional': {
+ 'str_dict': _DataTypes.input_str_dict(tooltip=(
+ "An optional input dictionary. If it's connected, it will be expanded/updated with new strings."
+ )),
+ },
+ 'hidden': {
+ 'unique_id': 'UNIQUE_ID', # used for text display at the bottom of the node
+ },
+})
+
+
+class StringConstructorDictFromText:
+ """
+ Build a dict of named sub-strings to be used later in string formatting (text construction).
+ """
+ NODE_NAME = 'StringConstructorDictFromText'
+ CATEGORY = _meta.category
+ DESCRIPTION = _format_docstring(_cleandoc(__doc__))
+
+ OUTPUT_NODE = True # Just to show the status message even if not connected to anything
+
+ FUNCTION = 'main'
+ RETURN_TYPES = (_DataTypes.STR_DICT, )
+ RETURN_NAMES = (_DataTypes.STR_DICT.lower(), )
+ # OUTPUT_TOOLTIPS = tuple()
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return _input_types
+
+ def main(
+ self, cleanup: bool, strings: str, show_status: bool,
+ str_dict: _t.Dict[str, _t.Any] = None,
+ unique_id: str = None,
+ ):
+ return _parse_dict_from_text(strings, strip_lines=cleanup, show=show_status, in_dict=str_dict, unique_id=unique_id)
diff --git a/node_dict_preview.py b/node_dict_preview.py
new file mode 100644
index 0000000..64a4b17
--- /dev/null
+++ b/node_dict_preview.py
@@ -0,0 +1,52 @@
+# encoding: utf-8
+"""
+"""
+
+import typing as _t
+
+from inspect import cleandoc as _cleandoc
+
+from frozendict import deepfreeze as _deepfreeze
+
+from . import _meta
+from .docstring_formatter import format_docstring as _format_docstring
+from .enums import DataTypes as _DataTypes
+from .funcs_dict_preview import preview_str_dict as _preview_str_dict
+
+
+# A tiny optimization by reusing the same immutable dict:
+_input_types = _deepfreeze({
+ 'required': {
+ 'str_dict': _DataTypes.input_str_dict(tooltip="A dictionary of named substrings."),
+ },
+ # 'optional': {},
+ 'hidden': {
+ 'unique_id': 'UNIQUE_ID', # used for text display at the bottom of the node
+ },
+})
+
+
+class StringConstructorDictPreview:
+ """
+ Show the contents of a Format-Dict.
+ """
+ NODE_NAME = 'StringConstructorDictPreview'
+ CATEGORY = _meta.category
+ DESCRIPTION = _format_docstring(_cleandoc(__doc__))
+
+ OUTPUT_NODE = True
+
+ FUNCTION = 'main'
+ RETURN_TYPES = (_DataTypes.STR_DICT, )
+ RETURN_NAMES = (_DataTypes.STR_DICT.lower(), )
+ # OUTPUT_TOOLTIPS = tuple()
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return _input_types
+
+ def main(
+ self, str_dict: _t.Dict[str, _t.Any],
+ unique_id: str = None,
+ ):
+ return _preview_str_dict(str_dict, unique_id=unique_id)
diff --git a/node_formatter.py b/node_formatter.py
new file mode 100644
index 0000000..c80fed2
--- /dev/null
+++ b/node_formatter.py
@@ -0,0 +1,68 @@
+# encoding: utf-8
+"""
+"""
+
+import typing as _t
+
+from inspect import cleandoc as _cleandoc
+
+from frozendict import deepfreeze as _deepfreeze
+
+from comfy.comfy_types.node_typing import IO as _IO
+
+from . import _meta
+from .docstring_formatter import format_docstring as _format_docstring
+from .enums import DataTypes as _DataTypes
+from .funcs_formatter import formatter as _formatter
+
+
+# A tiny optimization by reusing the same immutable dict:
+_input_types = _deepfreeze({
+ 'required': {
+ 'template': (_IO.STRING, {'multiline': True, 'tooltip': (
+ "Type the text template. "
+ "To reference named substrings from format-dictionary, use this syntax: {substring_name}. For example:\n\n"
+ "score_9, score_8_up, score_7_up, {char1_short}, standing next to {char2_short},\n"
+ "{char1_long}\n{char2_long}"
+ )}),
+ 'show_status': (_IO.BOOLEAN, {'default': True, 'label_on': 'formatted string', 'label_off': 'no', 'tooltip': (
+ "Show the final string constructed from the text-template and format-dictionary?"
+ )}),
+ },
+ 'optional': {
+ 'str_dict': _DataTypes.input_str_dict(tooltip=(
+ "The dictionary to take named sub-strings from. It could be left unconnected, if the pattern doesn't reference "
+ "any sub-strings - then, this node acts exactly the same as a regular string-primitive node."
+ )),
+ },
+ 'hidden': {
+ 'unique_id': 'UNIQUE_ID', # used for text display at the bottom of the node
+ },
+})
+
+
+class StringConstructorFormatter:
+ """
+ Construct the formatted string from template and format-dictionary.
+ """
+ NODE_NAME = 'StringConstructorFormatter'
+ CATEGORY = _meta.category
+ DESCRIPTION = _format_docstring(_cleandoc(__doc__))
+
+ OUTPUT_NODE = True
+
+ FUNCTION = 'main'
+ RETURN_TYPES = (_IO.STRING, )
+ RETURN_NAMES = ('string', )
+ # OUTPUT_TOOLTIPS = tuple()
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return _input_types
+
+ def main(
+ self, template: str, show_status: bool,
+ str_dict: _t.Dict[str, _t.Any] = None,
+ unique_id: str = None,
+ ):
+ return _formatter(template, str_dict, show=show_status, unique_id=unique_id)
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..52972d3
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,19 @@
+[project]
+name = "string-constructor"
+description = "Build your dictionary of named strings (text chunks), then easily compile different prompt variations from them - with simple string formatting.\nUnlike string-formatting nodes from other popular packs, this one is designed for specifying your sub-strings once, then dragging the entire \"library\" of them across the entire workflow (like a bus), updating it in-place as necessary. This is especially handy for regional prompting aka area composition."
+version = "0.0.1"
+authors = [
+ {name = "Lex Darlog"}
+]
+license = {file = "LICENSE.md"}
+readme = "README.md"
+dependencies = ["frozendict"]
+
+[project.urls]
+Repository = "https://github.com/Lex-DRL/ComfyUI-BestResolution"
+# Used by Comfy Registry https://comfyregistry.org
+
+[tool.comfy]
+PublisherId = "lex-drl"
+DisplayName = "String Constructor (Text-Formatting)"
+Icon = ""
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..302328f
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1 @@
+frozendict