init
This commit is contained in:
commit
c81b2e8d71
2 changed files with 257 additions and 0 deletions
44
README.md
Normal file
44
README.md
Normal file
|
@ -0,0 +1,44 @@
|
|||
# Mail Format
|
||||
|
||||
By default, this script accepts its input on `stdin` and prints to `stdout`.
|
||||
This makes it well suited for use with an editor like Helix. It has no
|
||||
dependencies besides the standard Python interpreter, and was written and tested
|
||||
against Python 3.13.2.
|
||||
|
||||
**Features:**
|
||||
|
||||
- Wraps emails at specified columns.
|
||||
- Automatically reflows paragraphs.
|
||||
- Squashes consecutive paragraph breaks.
|
||||
- Preserves:
|
||||
- Any long word not broken by spaces (e.g. URLs, email addresses).
|
||||
- Quoted lines.
|
||||
- Indented lines.
|
||||
- Lists.
|
||||
- Markdown-style code blocks.
|
||||
- Usenet-style signature block at EOF.
|
||||
- Sign-offs.
|
||||
|
||||
**Usage:**
|
||||
|
||||
```
|
||||
usage: format.py [-h] [-w WIDTH] [-b] [--no-replace-whitespace] [--no-reflow]
|
||||
[--no-signoff] [--no-signature] [--no-squash] [-i INPUT] [-o OUTPUT]
|
||||
|
||||
Formatter for plain text email.
|
||||
"--no-*" options are NOT passed by default.
|
||||
|
||||
options:
|
||||
-h, --help show this help message and exit
|
||||
-w, --width WIDTH Text width for wrapping. (default: 74)
|
||||
-b, --break-long-words
|
||||
Break long words while wrapping. (default: False)
|
||||
--no-replace-whitespace
|
||||
Don't normalize whitespace when wrapping.
|
||||
--no-reflow Don't reflow lines.
|
||||
--no-signoff Don't preserve signoff line breaks.
|
||||
--no-signature Don't preserve signature block.
|
||||
--no-squash Don't squash consecutive paragraph breaks.
|
||||
-i, --input INPUT Input file. (default: STDIN)
|
||||
-o, --output OUTPUT Output file. (default: STDOUT)
|
||||
```
|
213
mailfmt.py
Executable file
213
mailfmt.py
Executable file
|
@ -0,0 +1,213 @@
|
|||
#!/bin/env python
|
||||
|
||||
# Simple text-wrapping script for email.
|
||||
# Preserves code blocks, quotes, and signature.
|
||||
# Automatically joins and re-wraps paragraphs to
|
||||
# ensure even spacing & avoid ugly wrapping.
|
||||
# Preserves signoffs, eg: Yours truly,\nJohn Doe
|
||||
# Signoff heuristic:
|
||||
# 1-5 words ending with a comma, followed by
|
||||
# 1-5 words that each start with capital letters.
|
||||
# Author: Daniel Fichtinger
|
||||
# License: MIT
|
||||
|
||||
import textwrap
|
||||
import sys
|
||||
import re
|
||||
import argparse
|
||||
|
||||
paragraph: list[str] = []
|
||||
skipping = False
|
||||
squash = True
|
||||
prev_is_parbreak = False
|
||||
out_stream = sys.stdout
|
||||
reflow = True
|
||||
width = 74
|
||||
break_long_words = False
|
||||
replace_whitespace = True
|
||||
|
||||
|
||||
def pprint(string: str):
|
||||
if not squash:
|
||||
print(string, file=out_stream)
|
||||
else:
|
||||
parbreak = not string
|
||||
global prev_is_parbreak
|
||||
if skipping or not (parbreak and prev_is_parbreak):
|
||||
print(string, file=out_stream)
|
||||
prev_is_parbreak = parbreak
|
||||
|
||||
|
||||
def wrap(text: str):
|
||||
return textwrap.wrap(
|
||||
text,
|
||||
width=width,
|
||||
break_long_words=break_long_words,
|
||||
replace_whitespace=replace_whitespace,
|
||||
)
|
||||
|
||||
|
||||
def flush_paragraph():
|
||||
if paragraph:
|
||||
if reflow:
|
||||
joined = " ".join(paragraph)
|
||||
wrapped = wrap(joined)
|
||||
pprint("\n".join(wrapped))
|
||||
else:
|
||||
for line in paragraph:
|
||||
for wrapped_line in wrap(line):
|
||||
pprint(wrapped_line)
|
||||
paragraph.clear()
|
||||
|
||||
|
||||
signoff_cache: str = ""
|
||||
|
||||
|
||||
def check_signoff(line: str) -> bool:
|
||||
if not line:
|
||||
return False
|
||||
words = line.split()
|
||||
n = len(words)
|
||||
# first potential signoff line
|
||||
if not signoff_cache and 1 <= n <= 5 and line[-1] == ",":
|
||||
return True
|
||||
# second potential line
|
||||
elif signoff_cache and 1 <= n <= 5 and line[-1].isalpha():
|
||||
for w in words:
|
||||
if not w[0].isupper():
|
||||
return False
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Formatter for plain text email.\n"--no-*" options are NOT passed by default.',
|
||||
epilog="""
|
||||
Author : Daniel Fichtinger
|
||||
License: MIT
|
||||
Contact: daniel@ficd.ca
|
||||
""",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"-w",
|
||||
"--width",
|
||||
required=False,
|
||||
help="Text width for wrapping. (default: %(default)s)",
|
||||
default=width,
|
||||
type=int,
|
||||
)
|
||||
parser.add_argument(
|
||||
"-b",
|
||||
"--break-long-words",
|
||||
required=False,
|
||||
help="Break long words while wrapping. (default: %(default)s)",
|
||||
action="store_true",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-replace-whitespace",
|
||||
required=False,
|
||||
help="Don't normalize whitespace when wrapping.",
|
||||
action="store_false",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-reflow",
|
||||
required=False,
|
||||
help="Don't reflow lines.",
|
||||
action="store_false",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-signoff",
|
||||
required=False,
|
||||
help="Don't preserve signoff line breaks.",
|
||||
action="store_false",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-signature",
|
||||
required=False,
|
||||
help="Don't preserve signature block.",
|
||||
action="store_false",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-squash",
|
||||
required=False,
|
||||
help="Don't squash consecutive paragraph breaks.",
|
||||
action="store_false",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-i",
|
||||
"--input",
|
||||
required=False,
|
||||
type=str,
|
||||
default="STDIN",
|
||||
help="Input file. (default: %(default)s)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
required=False,
|
||||
type=str,
|
||||
default="STDOUT",
|
||||
help="Output file. (default: %(default)s)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
width = args.width
|
||||
should_check_signoff = args.no_signoff
|
||||
should_check_signature = args.no_signature
|
||||
reflow = args.no_reflow
|
||||
squash = args.no_squash
|
||||
replace_whitespace = args.no_replace_whitespace
|
||||
break_long_words = args.break_long_words
|
||||
|
||||
if args.input == "STDIN":
|
||||
reader = sys.stdin
|
||||
else:
|
||||
with open(args.input, "r") as in_stream:
|
||||
reader = in_stream
|
||||
if args.output != "STDOUT":
|
||||
out_stream = open(args.output, "w")
|
||||
|
||||
for line in reader:
|
||||
line = line.rstrip()
|
||||
if should_check_signoff:
|
||||
is_signoff = check_signoff(line)
|
||||
if is_signoff:
|
||||
if not signoff_cache:
|
||||
signoff_cache = line
|
||||
else:
|
||||
pprint(signoff_cache)
|
||||
pprint(line)
|
||||
signoff_cache = ""
|
||||
continue
|
||||
elif not is_signoff and signoff_cache:
|
||||
paragraph.append(signoff_cache)
|
||||
signoff_cache = ""
|
||||
if line.startswith("```"):
|
||||
flush_paragraph()
|
||||
skipping = not skipping
|
||||
pprint(line)
|
||||
elif should_check_signature and line == "--":
|
||||
flush_paragraph()
|
||||
skipping = True
|
||||
pprint("-- ")
|
||||
elif not line or re.match(
|
||||
r"^(\s+|-\s+|\+\s+|\*\s+|>\s*|#\s+|From:|To:|Cc:|Bcc:|Subject:|Reply-To:|In-Reply-To:|References:|Date:|Message-Id:|User-Agent:)",
|
||||
line,
|
||||
):
|
||||
flush_paragraph()
|
||||
pprint(line)
|
||||
elif skipping:
|
||||
pprint(line)
|
||||
else:
|
||||
paragraph.append(line)
|
||||
else:
|
||||
if signoff_cache:
|
||||
paragraph.append(signoff_cache)
|
||||
signoff_cache = ""
|
||||
flush_paragraph()
|
||||
if out_stream is not None:
|
||||
out_stream.close()
|
||||
|
||||
# pyright: basic
|
Loading…
Add table
Add a link
Reference in a new issue