Skip to main content

Flexible library to convert a text with custom markup to html (or anything else).

Project description

styled-text (Python version)

The Python version of the styled-text library. Designed for custom markup transformations.

This library is for anyone who wants to create styled text like markdown, but with total flexibility to create their own rules.

Installation

pip install styled-text

Usage

styled-text isn't just a markdown parser; it is a full AST (Abstract Syntax Tree) generator. You can build complex, nested, context-aware transpilers that go far beyond standard Regex search-and-replace.

It's not just for producing html/xml; since you define the transformations, it can output anything you want. Here are some simple examples for html, LaTeX, and ANSI.

HTML

Let's build a custom Discord-style chat formatter:

import re
from styled_text.text_styler import (
    TextStyler,
    TextStylerRule,
    TextStylerRegexRule,
    html_tag,
    ConsumptionStyle
)

# Let's create our custom syntax rules:
discord_rules = [
    # Basic matching (matches symmetrically, i.e. **bold**)
    TextStylerRule(start="**", transform=html_tag("strong")),

    TextStylerRule(
        start="||",
        end="||",
        transform=html_tag("span", {"class": "spoiler"})
    ),

    # Context-aware regex lookbehinds
    # (Matches "@username", but ONLY if preceded by whitespace or start of line)
    TextStylerRegexRule(
        regex=re.compile(r"(?<=^| )@([a-zA-Z0-9_]+)"),
        transform=lambda match: f"<a href='/users/{match.group(1)}'>@{match.group(1)}</a>"
    ),

    # (Transforms custom internal links `[[page]]` without consuming the brackets)
    # Two things being demonstrated here:
    #  1. We leave the tags in the output instead of consuming them
    #  2. The end tag is different from the start tag
    TextStylerRule(
        start="[[",
        end="]]",
        consume_start=ConsumptionStyle.OUTSIDE, # Leaves [[ in the output
        consume_end=ConsumptionStyle.OUTSIDE,   # Leaves ]] in the output
        transform=lambda text: f"<a href='/wiki/{text}'>{text}</a>"
    )
]

# Process it:
styler = TextStyler(discord_rules)
message = "Hello @admin, here is the **||[[Secret Code]]||**!"
html = styler.process_text(message)

# Output:
# Hello <a href='/users/admin'>@admin</a>, here is the <strong><span class='spoiler'>[[<a href='/wiki/Secret Code'>Secret Code</a>]]</span></strong>!

Out-of-the-Box Markdown

styled-text also provides a pre-defined Markdown ruleset. It supports headers, bold, italics, lists, quotes, inline code, blocks, and images!

Since it's just a list of rules, it's easy to modify, and a useful reference for how the library can be used.

from styled_text.markdown import markdown_rules
from styled_text.text_styler import TextStyler

styler = TextStyler(markdown_rules)

html = styler.process_text("""
# Welcome!
This is **bold** and *italic*.

- Item 1
- Item 2
""", multiline=True)

LaTeX

styled-text is not just an HTML tool, it is a general tool to transpile from anything to anything else. Here's a short example for producing LaTeX:

import re
from styled_text.text_styler import TextStyler, TextStylerRule, TextStylerRegexRule

latex_rules = [
    # Convert bold to \textbf{}
    TextStylerRule(
        start="**",
        transform=lambda text: f"\\textbf{{{text}}}"
    ),
    # Convert quotes to LaTeX blockquotes
    TextStylerRule(
        start=re.compile(r"^>\s+", re.MULTILINE),
        end=re.compile(r"(?=\n|$)\n?"),
        transform=lambda text: f"\\begin{{quote}}\n{text}\n\\end{{quote}}"
    ),
    # Convert an internal [[reference]] to a LaTeX \cite{}
    TextStylerRegexRule(
        regex=re.compile(r"\[\[(.*?)\]\]"),
        transform=lambda match: f"\\cite{{{match.group(1)}}}"
    )
]

styler = TextStyler(latex_rules)

academic_text = """
The study found that **performance increased** dramatically [[smith2023]].
> "The caching layer was the bottleneck."
"""

# Make sure to disable escape_html
latex_output = styler.process_text(academic_text, multiline=True, escape_html=False)

print(latex_output)
# Prints:
# The study found that \textbf{performance increased} dramatically \cite{smith2023}.
# \begin{quote}
# "The caching layer was the bottleneck."
# \end{quote}

ANSI color codes for a CLI tool

And here's an example to convert to ANSI color codes:

from styled_text.text_styler import TextStyler, TextStylerRule

# Standard terminal ANSI escape codes
class ANSI:
    RESET = "\033[0m"
    BOLD = "\033[1m"
    ITALIC = "\033[3m"
    RED = "\033[91m"
    CYAN = "\033[96m"

terminal_rules = [
    # Map Markdown-style stars to ANSI Bold/Italic
    TextStylerRule(
        start="**",
        transform=lambda text: f"{ANSI.BOLD}{text}{ANSI.RESET}"
    ),
    TextStylerRule(
        start="*",
        transform=lambda text: f"{ANSI.ITALIC}{text}{ANSI.RESET}"
    ),
    # Map BBCode-style tags to ANSI Colors
    TextStylerRule(
        start="[red]",
        end="[/red]",
        transform=lambda text: f"{ANSI.RED}{text}{ANSI.RESET}"
    ),
    TextStylerRule(
        start="[cyan]",
        end="[/cyan]",
        transform=lambda text: f"{ANSI.CYAN}{text}{ANSI.RESET}"
    ),
]

styler = TextStyler(terminal_rules)

raw_text = "CLI output can be **bold**, *italic*, or [red]colored[/red]! Nesting works for [cyan]**bold cyan**[/cyan] text too."

# Make sure to disable escape_html
cli_output = styler.process_text(raw_text, escape_html=False)

print(cli_output)

Why use styled-text instead of raw Regex?

If you tried to parse the string above using standard .replace() or standard global regex, nested tags (||...||) frequently overlap and corrupt each other, lookbehinds fail when preceding characters are sliced, XSS vulnerabilities are a constant concern, and if you have multiple regexes, the order you apply them will drastically affect the output.

styled-text safely evaluates the string hierarchically (converting it to an Abstract Syntax Tree first), uses memoization and dynamic programming for performance ($O(N)), and safely escapes HTML characters before output.

Examples

Simple bold

TextStylerRule(
  start='*',
  transform=html_tag("strong")
)

Input: My *bolded* text
Output (raw): My <strong>bolded</strong> text
Output (visual): My bolded text

Nested bold/italic

TextStylerRule(
  start='*',
  transform=html_tag("strong")
),
TextStylerRule(
  start='_',
  transform=html_tag("em")
)

Input: My *bolded and _italicized_ text*
Output (raw): My <strong>bolded and <em>italicized</em> text</strong>
Output (visual): My bolded and italicized text

Input: Three *asterisks* matches* eagerly
Output (raw): Three <strong>asterisks</strong> matches* eagerly
Output (visual): Three asterisks matches* eagerly

Input: Overlapping * tags _ also * matches _ eagerly
Output (raw): Overlapping <strong> tags _ also </strong> matches _ eagerly
Output (visual): Overlapping tags _ also matches _ eagerly

Nested / Conflicting Tags

Here we show two things:

  1. start can be multiple characters (~~ for strikethrough)
  2. one rule can be a subset of another, and it still works as expected (~ for subscript)
TextStylerRule(
  start="~",
  transform=html_tag("sub")
),
TextStylerRule(
  start="~~",
  transform=html_tag("del")
)

Input: H~~~3~~2~O
Output (raw): H<sub><del>3</del>2</sub>O
Output (visual): H32O

Input: A ~~~[sic]~tyop~~ typo is...
Output (raw): H<del><sub>[sic]<sub>tyop</del> typo is...
Output (visual): H[sic]tyop typo is...

Regexes

Regexes are the best way to built a complex replacement strategy, like if you need to parse the inner text into pieces, or use the inner text multiple times, such as in this example, where the matched url is used both as the property href and as the link text:

TextStylerRegexRule(
  regex=re.compile(r"https://www.[^\.]+.com),
  replace=r"<a href='\\g<0>'>\\g<0></a>"
)

Input: My link https://www.google.com
Output (raw): My link <a href='https://www.google.com'>https://www.google.com</a>
Output (visual): My link https://www.google.com

However, regexes are matched like literal strings, meaning that any styling within them is not matched by any other rules.
For example, even if we included the rule from asterisks to <strong> that we've used before, it will not use it to match within our regex:

Input: My link https://www.*google*.com
Output (raw): My link <a href='https://www.*google*.com'>https://www.*google*.com</a>
Output (visual): My link https://www.google.com

Preserving the special characters

By default, the special characters are removed from the output, but they can be preserved on the inside or on the outside:

TextStylerRule(
  start='*',
  transform=html_tag("strong"),
  consume_start=ConsumptionStyle.OUTSIDE,
  consume_end=ConsumptionStyle.OUTSIDE,
),
TextStylerRule(
  start='_',
  transform=html_tag("em")
  consume_start=ConsumptionStyle.INSIDE,
  consume_end=ConsumptionStyle.INSIDE,
)

Input: My *bolded* text, my _italicized_ text
Output (raw): My <strong>*bolded*</strong> text, my _<em>italicized</em>_ text
Output (visual): My *bolded* text, my _italicized_ text

Disallowing self-nesting

By default, a rule nesting within itself is allowed, but this can be disabled in two ways:

  1. Completely disallowed, at any depth
  2. A direct parent-child is disallowed, but grandparent-grandchild (or more distant) is allowed
TextStylerRule(
  start='*',
  transform=html_tag("strong"),
  allow_inner=InnerStyle.DISALLOW_DIRECT,
),
TextStylerRule(
  start='^',
  transform=html_tag("sup")
  allow_inner=InnerStyle.DISALLOW_ANCESTOR,
),
TextStylerRule(
  start='~',
  transform=html_tag("sub")
  allow_inner=InnerStyle.DISALLOW_DIRECT,
)

Input: Subscript ~cannot exist ~directly~ within subscript, but *can exist ~within~ the bolded* region~
Output (raw): Subscript <sub>cannot exist ~directly~ within subscript, but <strong>can exist <sub>within</sub> the bolded</strong> region</sub>
Output (visual): Subscript cannot exist ~directly~ within subscript, but can exist within the bolded region`

Input: Superscript ^of multiple depths is ^disallowed^, *even if we ^wrap^ it in a bolded* region^
Output (raw): Superscript <sup>of multiple depths is ^disallowed^, <strong>even if we ^wrap^ it in a bolded</strong> region</sup>
Output (visual): Superscript of multiple depths is ^disallowed^, even if we ^wrap^ it in a bolded region

Reference

To use the library, just set up a list of "rules", create a TextStyler object, then call process_text.

Class / Function Parameter Type Default Description
TextStyler rules list Required A list of TextStylerRule or TextStylerRegexRule objects.
TextStylerRegexRule regex str Required The regular expression pattern to match.
replace str Required The replacement string (supports regex capture groups like \1).
TextStylerRule start str Required The marker string that begins the rule.
transform Callable[str, str] Required "Function to process inner content (e.g., html_tag)."
end str start The marker string that terminates the rule.
consume_start ConsumptionType REPLACE "Determines if start is included in output (INSIDE, OUTSIDE, REPLACE)."
consume_end ConsumptionType REPLACE "Determines if end is included in output (INSIDE, OUTSIDE, REPLACE)."
allow_inner InnerStyle ALLOW "Determines if self-nesting is allowed (ALLOW, DISALLOW_DIRECT, DISALLOW_ANCESTOR)."
html_tag name str Required The HTML tag name (e.g., "strong").
attrs dict {} Optional HTML attributes (e.g., {"class": "my-css-class"}).

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

styled_text-1.4.0.tar.gz (15.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

styled_text-1.4.0-py3-none-any.whl (9.8 kB view details)

Uploaded Python 3

File details

Details for the file styled_text-1.4.0.tar.gz.

File metadata

  • Download URL: styled_text-1.4.0.tar.gz
  • Upload date:
  • Size: 15.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.16 {"installer":{"name":"uv","version":"0.11.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for styled_text-1.4.0.tar.gz
Algorithm Hash digest
SHA256 3134d75be6434abf5c34eb2f3e35afa4652aa7d0168fb089b22056406325a843
MD5 57112fcb67ef8a3809c317d81c6d4be5
BLAKE2b-256 e213112a23c519bd43b5357233b26cb61a55b8065ccbb9a48130b69bf729caf4

See more details on using hashes here.

File details

Details for the file styled_text-1.4.0-py3-none-any.whl.

File metadata

  • Download URL: styled_text-1.4.0-py3-none-any.whl
  • Upload date:
  • Size: 9.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.16 {"installer":{"name":"uv","version":"0.11.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for styled_text-1.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 d19e2af17314149cc8dcca8c25804010a767188d34efe75c8694a463351240e5
MD5 f167da8cd0382ba06050ef6741721124
BLAKE2b-256 a3bd072ec69aa5c728a3ec4089f2b13103d5705f3c1596bf73d28ca0149b0f59

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page