md2plain 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- md2plain/__init__.py +3 -0
- md2plain/__main__.py +3 -0
- md2plain/cli.py +143 -0
- md2plain/render.py +280 -0
- md2plain-0.1.0.dist-info/METADATA +201 -0
- md2plain-0.1.0.dist-info/RECORD +9 -0
- md2plain-0.1.0.dist-info/WHEEL +4 -0
- md2plain-0.1.0.dist-info/entry_points.txt +2 -0
- md2plain-0.1.0.dist-info/licenses/LICENSE +21 -0
md2plain/__init__.py
ADDED
md2plain/__main__.py
ADDED
md2plain/cli.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Convert Markdown to a plain-text email and copy it to the macOS clipboard.
|
|
3
|
+
|
|
4
|
+
Only text/plain is placed on the clipboard, so pasting into Gmail's
|
|
5
|
+
"Plain text mode" produces a real text/plain message.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
md2plain note.md
|
|
9
|
+
md2plain note.md --width 72
|
|
10
|
+
md2plain note.md --watch
|
|
11
|
+
md2plain note.md --print
|
|
12
|
+
md2plain note.md -o out.txt
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from . import __version__
|
|
24
|
+
from .render import render_plain
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def positive_width(value: str) -> int:
|
|
28
|
+
width = int(value)
|
|
29
|
+
if width < 1:
|
|
30
|
+
raise argparse.ArgumentTypeError("width must be at least 1")
|
|
31
|
+
return width
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def read_input(path: str) -> str:
|
|
35
|
+
if path == "-":
|
|
36
|
+
return sys.stdin.read()
|
|
37
|
+
try:
|
|
38
|
+
return Path(path).read_text(encoding="utf-8-sig", errors="replace")
|
|
39
|
+
except FileNotFoundError:
|
|
40
|
+
sys.exit(f"File not found: {path}")
|
|
41
|
+
except OSError as exc:
|
|
42
|
+
sys.exit(f"Cannot read {path}: {exc}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def copy_text(text: str) -> None:
|
|
46
|
+
"""Write plain text only, so Gmail's plain-text mode gets it verbatim."""
|
|
47
|
+
from AppKit import NSPasteboard, NSPasteboardTypeString
|
|
48
|
+
|
|
49
|
+
name = os.environ.get("MD2PLAIN_PASTEBOARD", "general")
|
|
50
|
+
board = (
|
|
51
|
+
NSPasteboard.generalPasteboard()
|
|
52
|
+
if name == "general"
|
|
53
|
+
else NSPasteboard.pasteboardWithName_(name)
|
|
54
|
+
)
|
|
55
|
+
board.clearContents()
|
|
56
|
+
board.declareTypes_owner_([NSPasteboardTypeString], None)
|
|
57
|
+
board.setString_forType_(text, NSPasteboardTypeString)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def finish(text: str, args: argparse.Namespace) -> None:
|
|
61
|
+
if args.output:
|
|
62
|
+
Path(args.output).write_text(text, encoding="utf-8")
|
|
63
|
+
if args.show:
|
|
64
|
+
sys.stdout.write(text)
|
|
65
|
+
if args.no_copy:
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
copy_text(text)
|
|
70
|
+
except ImportError:
|
|
71
|
+
sys.exit("pyobjc is not installed; use --print or -o instead")
|
|
72
|
+
|
|
73
|
+
print(
|
|
74
|
+
f"Copied {len(text.splitlines())} lines / {len(text)} chars of plain text. "
|
|
75
|
+
"Enable Gmail's Plain text mode, then press Cmd+V.",
|
|
76
|
+
file=sys.stderr,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def main() -> int:
|
|
81
|
+
parser = argparse.ArgumentParser(
|
|
82
|
+
prog="md2plain",
|
|
83
|
+
description="Convert Markdown to plain-text email and copy it to the clipboard.",
|
|
84
|
+
)
|
|
85
|
+
parser.add_argument(
|
|
86
|
+
"--version", action="version", version=f"%(prog)s {__version__}"
|
|
87
|
+
)
|
|
88
|
+
parser.add_argument("input", help="Markdown file, or - for stdin")
|
|
89
|
+
parser.add_argument("-o", "--output", help="also write the plain text to a file")
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--print", dest="show", action="store_true", help="write to stdout"
|
|
92
|
+
)
|
|
93
|
+
parser.add_argument(
|
|
94
|
+
"--no-copy", action="store_true", help="do not touch the clipboard"
|
|
95
|
+
)
|
|
96
|
+
parser.add_argument(
|
|
97
|
+
"--watch", action="store_true", help="update the clipboard on save"
|
|
98
|
+
)
|
|
99
|
+
parser.add_argument(
|
|
100
|
+
"--width", type=positive_width, default=75, help="line width (default: 75)"
|
|
101
|
+
)
|
|
102
|
+
parser.add_argument(
|
|
103
|
+
"--soft-breaks",
|
|
104
|
+
action="store_true",
|
|
105
|
+
help="treat a single newline as a space (default: keep line breaks)",
|
|
106
|
+
)
|
|
107
|
+
args = parser.parse_args()
|
|
108
|
+
|
|
109
|
+
if args.watch and args.input == "-":
|
|
110
|
+
parser.error("--watch requires a file, not stdin")
|
|
111
|
+
|
|
112
|
+
text = read_input(args.input)
|
|
113
|
+
if not text.strip():
|
|
114
|
+
sys.exit("Input is empty.")
|
|
115
|
+
|
|
116
|
+
preserve_breaks = not args.soft_breaks
|
|
117
|
+
|
|
118
|
+
def convert(src: str) -> None:
|
|
119
|
+
finish(render_plain(src, args.width, preserve_breaks), args)
|
|
120
|
+
|
|
121
|
+
if not args.watch:
|
|
122
|
+
convert(text)
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
last_mtime: float | None = None
|
|
126
|
+
try:
|
|
127
|
+
while True:
|
|
128
|
+
try:
|
|
129
|
+
mtime = os.stat(args.input).st_mtime
|
|
130
|
+
except FileNotFoundError:
|
|
131
|
+
time.sleep(1)
|
|
132
|
+
continue
|
|
133
|
+
if mtime != last_mtime:
|
|
134
|
+
convert(read_input(args.input))
|
|
135
|
+
last_mtime = mtime
|
|
136
|
+
time.sleep(1)
|
|
137
|
+
except KeyboardInterrupt:
|
|
138
|
+
print("\nStopped watching.", file=sys.stderr)
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
raise SystemExit(main())
|
md2plain/render.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Render Markdown as plain-text email in reStructuredText style.
|
|
2
|
+
|
|
3
|
+
Headings use = / - / ~ underlines, paragraphs are hard-wrapped at 75
|
|
4
|
+
columns (CJK counts as two), lists use "*", quotes use ">", and code is
|
|
5
|
+
indented by four spaces.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import unicodedata
|
|
12
|
+
|
|
13
|
+
from markdown_it import MarkdownIt
|
|
14
|
+
from markdown_it.tree import SyntaxTreeNode
|
|
15
|
+
|
|
16
|
+
UNDERLINE = {1: "=", 2: "-", 3: "~", 4: "^", 5: '"', 6: "'"}
|
|
17
|
+
QUOTE = ">"
|
|
18
|
+
|
|
19
|
+
# Punctuation that must not start a line (CJK line-breaking rule).
|
|
20
|
+
NO_LINE_START = set("。,、;:!?)〕]}》」』】’”…·%,.;:!?)]}'\"")
|
|
21
|
+
# Link schemes that are safe to keep.
|
|
22
|
+
SAFE_LINK = re.compile(r"^(https?://|mailto:|#)", re.IGNORECASE)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def char_width(ch: str) -> int:
|
|
26
|
+
"""Display width of one character: 2 for wide (CJK) characters."""
|
|
27
|
+
return 2 if unicodedata.east_asian_width(ch) in "WF" else 1
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def disp_width(text: str) -> int:
|
|
31
|
+
return sum(char_width(c) for c in text)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# --------------------------------------------------------------------- inline
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def inline_text(node: SyntaxTreeNode, with_urls: bool = True) -> str:
|
|
38
|
+
"""Flatten an inline node to text; links become "label (url)"."""
|
|
39
|
+
kind = node.type
|
|
40
|
+
|
|
41
|
+
if kind in ("text", "code_inline"):
|
|
42
|
+
return node.content
|
|
43
|
+
if kind == "softbreak":
|
|
44
|
+
return " "
|
|
45
|
+
if kind == "hardbreak":
|
|
46
|
+
return "\n"
|
|
47
|
+
if kind == "image":
|
|
48
|
+
return node.content or (node.attrs.get("src") or "")
|
|
49
|
+
if kind == "html_inline":
|
|
50
|
+
return ""
|
|
51
|
+
if kind == "link":
|
|
52
|
+
label = "".join(inline_text(c, False) for c in node.children)
|
|
53
|
+
href = node.attrs.get("href") or ""
|
|
54
|
+
if not with_urls or not href or not SAFE_LINK.match(href):
|
|
55
|
+
return label
|
|
56
|
+
# Do not repeat a bare address: mailto:a@b.com with label a@b.com.
|
|
57
|
+
if href.lower().startswith("mailto:") and href[7:] == label:
|
|
58
|
+
return label
|
|
59
|
+
# A URL used as its own label needs no "(url)" suffix, even when the
|
|
60
|
+
# target only differs by tracking parameters.
|
|
61
|
+
if label.startswith(("http://", "https://")):
|
|
62
|
+
base = href.split("?", 1)[0].split("#", 1)[0].rstrip("/")
|
|
63
|
+
if base == label.rstrip("/"):
|
|
64
|
+
return label
|
|
65
|
+
if href != label:
|
|
66
|
+
return f"{label} ({href})"
|
|
67
|
+
return label
|
|
68
|
+
|
|
69
|
+
return "".join(inline_text(c, with_urls) for c in node.children)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def plain_of(node: SyntaxTreeNode) -> str:
|
|
73
|
+
return inline_text(node, with_urls=False)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ----------------------------------------------------------------------- wrap
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def wrap_line(line: str, width: int) -> list[str]:
|
|
80
|
+
"""Wrap one line at spaces, breaking CJK text between characters."""
|
|
81
|
+
line = re.sub(r"[ \t]+", " ", line).strip()
|
|
82
|
+
if not line:
|
|
83
|
+
return [""]
|
|
84
|
+
|
|
85
|
+
out: list[str] = []
|
|
86
|
+
current: list[str] = []
|
|
87
|
+
current_width = 0
|
|
88
|
+
last_space = -1
|
|
89
|
+
|
|
90
|
+
for ch in line:
|
|
91
|
+
w = char_width(ch)
|
|
92
|
+
if current and current_width + w > width:
|
|
93
|
+
if ch in NO_LINE_START:
|
|
94
|
+
current.append(ch)
|
|
95
|
+
current_width += w
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
if last_space > 0:
|
|
99
|
+
out.append("".join(current[:last_space]).rstrip())
|
|
100
|
+
current = current[last_space + 1 :]
|
|
101
|
+
else:
|
|
102
|
+
out.append("".join(current).rstrip())
|
|
103
|
+
current = []
|
|
104
|
+
|
|
105
|
+
current_width = sum(char_width(c) for c in current)
|
|
106
|
+
last_space = -1
|
|
107
|
+
for i, c in enumerate(current):
|
|
108
|
+
if c == " ":
|
|
109
|
+
last_space = i
|
|
110
|
+
|
|
111
|
+
current.append(ch)
|
|
112
|
+
current_width += w
|
|
113
|
+
if ch == " ":
|
|
114
|
+
last_space = len(current) - 1
|
|
115
|
+
|
|
116
|
+
if current:
|
|
117
|
+
out.append("".join(current).rstrip())
|
|
118
|
+
# Very small widths can produce blank lines; keep the result non-empty.
|
|
119
|
+
return [line for line in out if line] or [""]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def wrap_text(text: str, width: int) -> list[str]:
|
|
123
|
+
lines: list[str] = []
|
|
124
|
+
for part in text.split("\n"):
|
|
125
|
+
lines.extend(wrap_line(part, width))
|
|
126
|
+
return lines
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# --------------------------------------------------------------------- blocks
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def render_blocks(nodes: list[SyntaxTreeNode], width: int) -> list[str]:
|
|
133
|
+
out: list[str] = []
|
|
134
|
+
|
|
135
|
+
for node in nodes:
|
|
136
|
+
kind = node.type
|
|
137
|
+
|
|
138
|
+
if kind == "heading":
|
|
139
|
+
level = int(node.tag[1])
|
|
140
|
+
text = plain_of(node.children[0]).strip()
|
|
141
|
+
if not text:
|
|
142
|
+
continue
|
|
143
|
+
mark = UNDERLINE.get(level, "-")
|
|
144
|
+
out += [text, mark * max(disp_width(text), 3), ""]
|
|
145
|
+
|
|
146
|
+
elif kind == "paragraph":
|
|
147
|
+
out += wrap_text(inline_text(node.children[0]), width)
|
|
148
|
+
out.append("")
|
|
149
|
+
|
|
150
|
+
elif kind in ("bullet_list", "ordered_list"):
|
|
151
|
+
out += render_list(node, width)
|
|
152
|
+
|
|
153
|
+
elif kind == "blockquote":
|
|
154
|
+
body = render_blocks(node.children, width - 2)
|
|
155
|
+
while body and not body[-1]:
|
|
156
|
+
body.pop()
|
|
157
|
+
out += [QUOTE if not line else f"{QUOTE} {line}" for line in body]
|
|
158
|
+
out.append("")
|
|
159
|
+
|
|
160
|
+
elif kind in ("fence", "code_block"):
|
|
161
|
+
code = (node.content or "").rstrip("\n").split("\n")
|
|
162
|
+
out += [" " + line for line in code]
|
|
163
|
+
out.append("")
|
|
164
|
+
|
|
165
|
+
elif kind == "hr":
|
|
166
|
+
out += ["-" * min(width, 72), ""]
|
|
167
|
+
|
|
168
|
+
elif kind == "table":
|
|
169
|
+
out += render_table(node, width)
|
|
170
|
+
out.append("")
|
|
171
|
+
|
|
172
|
+
elif kind == "html_block":
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
elif node.children:
|
|
176
|
+
out += render_blocks(node.children, width)
|
|
177
|
+
|
|
178
|
+
return out
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def render_list(node: SyntaxTreeNode, width: int) -> list[str]:
|
|
182
|
+
ordered = node.type == "ordered_list"
|
|
183
|
+
start = int(node.attrs.get("start", 1)) if ordered else None
|
|
184
|
+
out: list[str] = []
|
|
185
|
+
|
|
186
|
+
for i, item in enumerate(node.children):
|
|
187
|
+
marker = f"{start + i}. " if start is not None else "* "
|
|
188
|
+
body = render_blocks(item.children, width - len(marker))
|
|
189
|
+
while body and not body[-1]:
|
|
190
|
+
body.pop()
|
|
191
|
+
|
|
192
|
+
# Keep nested lists tight: drop the blank line before them.
|
|
193
|
+
tight: list[str] = []
|
|
194
|
+
for k, line in enumerate(body):
|
|
195
|
+
if not line:
|
|
196
|
+
nxt = k + 1
|
|
197
|
+
while nxt < len(body) and not body[nxt]:
|
|
198
|
+
nxt += 1
|
|
199
|
+
if nxt < len(body) and re.match(r"^(\* |\d+\. )", body[nxt]):
|
|
200
|
+
continue
|
|
201
|
+
tight.append(line)
|
|
202
|
+
|
|
203
|
+
for j, line in enumerate(tight):
|
|
204
|
+
out.append((marker if j == 0 else " " * len(marker)) + line)
|
|
205
|
+
|
|
206
|
+
out.append("")
|
|
207
|
+
return out
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def render_table(node: SyntaxTreeNode, width: int) -> list[str]:
|
|
211
|
+
rows: list[list[str]] = []
|
|
212
|
+
for section in node.children:
|
|
213
|
+
for tr in section.children:
|
|
214
|
+
cells = [
|
|
215
|
+
"".join(plain_of(c) for c in cell.children).strip()
|
|
216
|
+
for cell in tr.children
|
|
217
|
+
]
|
|
218
|
+
if cells:
|
|
219
|
+
rows.append(cells)
|
|
220
|
+
if not rows:
|
|
221
|
+
return []
|
|
222
|
+
|
|
223
|
+
ncols = max(len(row) for row in rows)
|
|
224
|
+
for row in rows:
|
|
225
|
+
row.extend([""] * (ncols - len(row)))
|
|
226
|
+
|
|
227
|
+
widths = [max(disp_width(row[c]) for row in rows) for c in range(ncols)]
|
|
228
|
+
while sum(widths) + 2 * (ncols - 1) > width and max(widths) > 4:
|
|
229
|
+
widths[widths.index(max(widths))] -= 1
|
|
230
|
+
|
|
231
|
+
def fmt(row: list[str]) -> str:
|
|
232
|
+
padded = []
|
|
233
|
+
for c, cell in enumerate(row):
|
|
234
|
+
padded.append(cell + " " * max(widths[c] - disp_width(cell), 0))
|
|
235
|
+
return " ".join(padded).rstrip()
|
|
236
|
+
|
|
237
|
+
lines = [fmt(rows[0]), " ".join("-" * w for w in widths)]
|
|
238
|
+
lines += [fmt(row) for row in rows[1:]]
|
|
239
|
+
return lines
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# --------------------------------------------------------------------- output
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def parse(md_text: str, preserve_breaks: bool = True) -> SyntaxTreeNode:
|
|
246
|
+
md = MarkdownIt(
|
|
247
|
+
"commonmark", {"html": False, "linkify": True, "typographer": False}
|
|
248
|
+
)
|
|
249
|
+
md.enable(["table", "strikethrough", "linkify"])
|
|
250
|
+
tokens = md.parse(md_text)
|
|
251
|
+
|
|
252
|
+
if preserve_breaks:
|
|
253
|
+
# A single newline usually means a real line break here (signatures,
|
|
254
|
+
# dates), unlike CommonMark's default of joining it with a space.
|
|
255
|
+
for token in tokens:
|
|
256
|
+
if token.type == "inline" and token.children:
|
|
257
|
+
for child in token.children:
|
|
258
|
+
if child.type == "softbreak":
|
|
259
|
+
child.type = "hardbreak"
|
|
260
|
+
|
|
261
|
+
return SyntaxTreeNode(tokens)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def tidy(lines: list[str]) -> list[str]:
|
|
265
|
+
out: list[str] = []
|
|
266
|
+
for line in lines:
|
|
267
|
+
line = line.rstrip()
|
|
268
|
+
if not line and out and not out[-1]:
|
|
269
|
+
continue
|
|
270
|
+
out.append(line)
|
|
271
|
+
while out and not out[0]:
|
|
272
|
+
out.pop(0)
|
|
273
|
+
while out and not out[-1]:
|
|
274
|
+
out.pop()
|
|
275
|
+
return out
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def render_plain(md_text: str, width: int = 75, preserve_breaks: bool = True) -> str:
|
|
279
|
+
lines = tidy(render_blocks(parse(md_text, preserve_breaks).children, width))
|
|
280
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: md2plain
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Convert Markdown to plain-text email for Gmail's Plain text mode.
|
|
5
|
+
Project-URL: Homepage, https://github.com/hoshF/md2plain
|
|
6
|
+
Project-URL: Issues, https://github.com/hoshF/md2plain/issues
|
|
7
|
+
Author-email: hoshF <noveor.ol@gmail.com>
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 hoshF
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Keywords: cli,email,gmail,markdown,plain-text
|
|
31
|
+
Classifier: Environment :: Console
|
|
32
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
33
|
+
Classifier: Operating System :: MacOS
|
|
34
|
+
Classifier: Programming Language :: Python :: 3
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
38
|
+
Classifier: Topic :: Communications :: Email
|
|
39
|
+
Classifier: Topic :: Text Processing :: Markup
|
|
40
|
+
Requires-Python: >=3.10
|
|
41
|
+
Requires-Dist: linkify-it-py>=2.0
|
|
42
|
+
Requires-Dist: markdown-it-py>=3.0
|
|
43
|
+
Requires-Dist: pyobjc-framework-cocoa>=10.0; sys_platform == 'darwin'
|
|
44
|
+
Provides-Extra: dev
|
|
45
|
+
Requires-Dist: black>=24; extra == 'dev'
|
|
46
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
47
|
+
Description-Content-Type: text/markdown
|
|
48
|
+
|
|
49
|
+
# md2plain
|
|
50
|
+
|
|
51
|
+
[](https://github.com/hoshF/md2plain/actions/workflows/ci.yml)
|
|
52
|
+
[](LICENSE)
|
|
53
|
+
[](pyproject.toml)
|
|
54
|
+
[](pyproject.toml)
|
|
55
|
+
|
|
56
|
+
**Markdown in, plain-text email out.**
|
|
57
|
+
|
|
58
|
+
`md2plain` renders Markdown as a plain-text email and copies it to the macOS
|
|
59
|
+
clipboard as `text/plain`. Paste it into Gmail's **Plain text mode** and the
|
|
60
|
+
message is sent as a real `text/plain` message: hard-wrapped, with no HTML,
|
|
61
|
+
no fonts, and no client-side reflow.
|
|
62
|
+
|
|
63
|
+
## Demo
|
|
64
|
+
|
|
65
|
+
Input:
|
|
66
|
+
|
|
67
|
+
```markdown
|
|
68
|
+
# Quarterly Update
|
|
69
|
+
|
|
70
|
+
This is a **short** note with a [link](https://example.com).
|
|
71
|
+
|
|
72
|
+
* one
|
|
73
|
+
* two
|
|
74
|
+
|
|
75
|
+
> quoted
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Output:
|
|
79
|
+
|
|
80
|
+
```text
|
|
81
|
+
Quarterly Update
|
|
82
|
+
================
|
|
83
|
+
|
|
84
|
+
This is a short note with a link (https://example.com).
|
|
85
|
+
|
|
86
|
+
* one
|
|
87
|
+
* two
|
|
88
|
+
|
|
89
|
+
> quoted
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Why
|
|
93
|
+
|
|
94
|
+
- Gmail's normal compose always sends HTML, even when you paste plain text.
|
|
95
|
+
- Rich text copied from Typora carries its own CSS, which mail clients mangle.
|
|
96
|
+
- Plain text has no automatic reflow, so the sender has to control line width.
|
|
97
|
+
- A 75-column limit leaves room for `> ` quote prefixes and stays within
|
|
98
|
+
RFC 5322's recommended 78 characters.
|
|
99
|
+
|
|
100
|
+
## Requirements
|
|
101
|
+
|
|
102
|
+
- macOS (the clipboard is written through AppKit)
|
|
103
|
+
- Python 3.10 or newer
|
|
104
|
+
|
|
105
|
+
## Install
|
|
106
|
+
|
|
107
|
+
With [pipx](https://pipx.pypa.io/):
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
pipx install .
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Or from a checkout, in a virtual environment:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
python3 -m venv .venv
|
|
117
|
+
.venv/bin/pip install -e .
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Usage
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
md2plain note.md
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Then in Gmail: open the compose window, click the three-dot menu, choose
|
|
127
|
+
**Plain text mode**, and press `Cmd+V`.
|
|
128
|
+
|
|
129
|
+
To verify, send a message to yourself and use **Show original**. The message
|
|
130
|
+
should contain only:
|
|
131
|
+
|
|
132
|
+
```text
|
|
133
|
+
Content-Type: text/plain; charset="UTF-8"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
No `multipart/alternative`, no `text/html`.
|
|
137
|
+
|
|
138
|
+
## Options
|
|
139
|
+
|
|
140
|
+
| Option | Description |
|
|
141
|
+
|---|---|
|
|
142
|
+
| `--width N` | Line width in columns (default: 75) |
|
|
143
|
+
| `--watch` | Update the clipboard whenever the file is saved |
|
|
144
|
+
| `--print` | Write to stdout instead of the clipboard |
|
|
145
|
+
| `-o, --output FILE` | Also write the plain text to a file |
|
|
146
|
+
| `--soft-breaks` | Treat single newlines as spaces |
|
|
147
|
+
| `--version` | Print the version and exit |
|
|
148
|
+
|
|
149
|
+
## Output format
|
|
150
|
+
|
|
151
|
+
| Markdown | Output |
|
|
152
|
+
|---|---|
|
|
153
|
+
| `# Heading` | heading + `====` underline |
|
|
154
|
+
| `## Heading` | heading + `----` underline |
|
|
155
|
+
| `### Heading` | heading + `~~~~` underline |
|
|
156
|
+
| Paragraph | hard-wrapped at 75 columns (CJK counts as two) |
|
|
157
|
+
| `- item` | `* item`, with hanging indent |
|
|
158
|
+
| `1. item` | `1. item` |
|
|
159
|
+
| `> quote` | `> quote` |
|
|
160
|
+
| Code block | indented by four spaces |
|
|
161
|
+
| `[text](url)` | `text (url)` |
|
|
162
|
+
| Table | column-aligned plain text |
|
|
163
|
+
| Single newline | kept (signatures and dates stay on their own lines) |
|
|
164
|
+
|
|
165
|
+
## How it works
|
|
166
|
+
|
|
167
|
+
1. `markdown-it-py` parses CommonMark plus tables, strikethrough, and
|
|
168
|
+
autolinks.
|
|
169
|
+
2. `src/md2plain/render.py` wraps text by display width (CJK counts as two
|
|
170
|
+
columns), renders headings, lists, quotes, and tables.
|
|
171
|
+
3. `src/md2plain/cli.py` reads UTF-8 (BOM tolerant) and writes exactly one
|
|
172
|
+
clipboard type: `text/plain`.
|
|
173
|
+
|
|
174
|
+
## Limitations
|
|
175
|
+
|
|
176
|
+
- macOS only; the clipboard backend is AppKit.
|
|
177
|
+
- Plain text has no bold, italics, or clickable links. Link URLs are kept as
|
|
178
|
+
text, and links inside headings or table cells lose the URL.
|
|
179
|
+
- Images become alt text. YAML front matter, footnotes, math, and Mermaid
|
|
180
|
+
diagrams are not converted.
|
|
181
|
+
- Tables are aligned with spaces, so proportional fonts break the alignment.
|
|
182
|
+
- Gmail may collapse `>` quote blocks behind a three-dot control; this is
|
|
183
|
+
display only and the content stays intact.
|
|
184
|
+
|
|
185
|
+
## Development
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
python3 -m venv .venv
|
|
189
|
+
.venv/bin/pip install -e ".[dev]"
|
|
190
|
+
.venv/bin/python -m unittest discover -s tests -v
|
|
191
|
+
.venv/bin/ruff check src tests
|
|
192
|
+
.venv/bin/black --check src tests
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
The `./md2plain` script in the repository root is a development launcher; it
|
|
196
|
+
runs the package from `.venv`. Installed users get the `md2plain` command from
|
|
197
|
+
`pyproject.toml`.
|
|
198
|
+
|
|
199
|
+
## License
|
|
200
|
+
|
|
201
|
+
[MIT](LICENSE). See [CHANGELOG.md](CHANGELOG.md) for release history.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
md2plain/__init__.py,sha256=cf1lnlvj00XKs2SZvzGpZeoYCKmNbgW7EBzgzasGNrQ,69
|
|
2
|
+
md2plain/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
md2plain/cli.py,sha256=cafYGn2QUUGTWZuiaeORxaAY8EAyiS6EsXizAIczPgM,4115
|
|
4
|
+
md2plain/render.py,sha256=MBOcaIn93exuHtlYnokwertziBERQF0QdNhq0b9lTlI,8925
|
|
5
|
+
md2plain-0.1.0.dist-info/METADATA,sha256=PByUSBWwq-jKCrCpGJoEDm0i6XzWwEjDzVw8YYHVo3Q,6505
|
|
6
|
+
md2plain-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
7
|
+
md2plain-0.1.0.dist-info/entry_points.txt,sha256=I92mxKS_-_eLAu5PleHXkvN46RkSuCHK8Fkj2NgPeKM,47
|
|
8
|
+
md2plain-0.1.0.dist-info/licenses/LICENSE,sha256=yz5qS2XkiEU3inW-ky4zpr1M2rXdnhxI8NkMPBgFsoM,1062
|
|
9
|
+
md2plain-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hoshF
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|