md2plain 0.1.0__tar.gz

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.
@@ -0,0 +1,30 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ name: ${{ matrix.os }} / Python ${{ matrix.python-version }}
10
+ runs-on: ${{ matrix.os }}
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ os: [ubuntu-latest, macos-latest]
15
+ python-version: ["3.10", "3.12"]
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: actions/setup-python@v5
19
+ with:
20
+ python-version: ${{ matrix.python-version }}
21
+ - name: Install
22
+ run: |
23
+ python -m pip install --upgrade pip
24
+ python -m pip install -e ".[dev]"
25
+ - name: Test
26
+ run: python -m unittest discover -s tests -v
27
+ - name: Lint
28
+ run: |
29
+ ruff check src tests
30
+ black --check src tests
@@ -0,0 +1,16 @@
1
+ # Python
2
+ *.egg-info/
3
+ build/
4
+ dist/
5
+ __pycache__/
6
+ *.py[cod]
7
+ .venv/
8
+ .ruff_cache/
9
+
10
+ # macOS
11
+ .DS_Store
12
+
13
+ # Local test letters
14
+ test.md
15
+ test2.md
16
+ test3.md
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is based
4
+ on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.1.0] - 2026-10-18
8
+
9
+ ### Added
10
+
11
+ - Markdown to plain-text email rendering: `=` / `-` / `~` headings, `*` lists,
12
+ `>` quotes, four-space code blocks, and 75-column hard wrapping with CJK
13
+ width support.
14
+ - Clipboard output as `text/plain` only, for Gmail's Plain text mode.
15
+ - CLI options: `--width`, `--watch`, `--print`, `-o/--output`,
16
+ `--soft-breaks`, and `--version`.
17
+ - Test suite covering the renderer, CLI, clipboard, edge cases, fuzzing, and
18
+ performance.
md2plain-0.1.0/LICENSE ADDED
@@ -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.
@@ -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
+ [![CI](https://github.com/hoshF/md2plain/actions/workflows/ci.yml/badge.svg)](https://github.com/hoshF/md2plain/actions/workflows/ci.yml)
52
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
53
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](pyproject.toml)
54
+ [![Platform: macOS](https://img.shields.io/badge/platform-macOS-lightgrey)](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,153 @@
1
+ # md2plain
2
+
3
+ [![CI](https://github.com/hoshF/md2plain/actions/workflows/ci.yml/badge.svg)](https://github.com/hoshF/md2plain/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
5
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](pyproject.toml)
6
+ [![Platform: macOS](https://img.shields.io/badge/platform-macOS-lightgrey)](pyproject.toml)
7
+
8
+ **Markdown in, plain-text email out.**
9
+
10
+ `md2plain` renders Markdown as a plain-text email and copies it to the macOS
11
+ clipboard as `text/plain`. Paste it into Gmail's **Plain text mode** and the
12
+ message is sent as a real `text/plain` message: hard-wrapped, with no HTML,
13
+ no fonts, and no client-side reflow.
14
+
15
+ ## Demo
16
+
17
+ Input:
18
+
19
+ ```markdown
20
+ # Quarterly Update
21
+
22
+ This is a **short** note with a [link](https://example.com).
23
+
24
+ * one
25
+ * two
26
+
27
+ > quoted
28
+ ```
29
+
30
+ Output:
31
+
32
+ ```text
33
+ Quarterly Update
34
+ ================
35
+
36
+ This is a short note with a link (https://example.com).
37
+
38
+ * one
39
+ * two
40
+
41
+ > quoted
42
+ ```
43
+
44
+ ## Why
45
+
46
+ - Gmail's normal compose always sends HTML, even when you paste plain text.
47
+ - Rich text copied from Typora carries its own CSS, which mail clients mangle.
48
+ - Plain text has no automatic reflow, so the sender has to control line width.
49
+ - A 75-column limit leaves room for `> ` quote prefixes and stays within
50
+ RFC 5322's recommended 78 characters.
51
+
52
+ ## Requirements
53
+
54
+ - macOS (the clipboard is written through AppKit)
55
+ - Python 3.10 or newer
56
+
57
+ ## Install
58
+
59
+ With [pipx](https://pipx.pypa.io/):
60
+
61
+ ```bash
62
+ pipx install .
63
+ ```
64
+
65
+ Or from a checkout, in a virtual environment:
66
+
67
+ ```bash
68
+ python3 -m venv .venv
69
+ .venv/bin/pip install -e .
70
+ ```
71
+
72
+ ## Usage
73
+
74
+ ```bash
75
+ md2plain note.md
76
+ ```
77
+
78
+ Then in Gmail: open the compose window, click the three-dot menu, choose
79
+ **Plain text mode**, and press `Cmd+V`.
80
+
81
+ To verify, send a message to yourself and use **Show original**. The message
82
+ should contain only:
83
+
84
+ ```text
85
+ Content-Type: text/plain; charset="UTF-8"
86
+ ```
87
+
88
+ No `multipart/alternative`, no `text/html`.
89
+
90
+ ## Options
91
+
92
+ | Option | Description |
93
+ |---|---|
94
+ | `--width N` | Line width in columns (default: 75) |
95
+ | `--watch` | Update the clipboard whenever the file is saved |
96
+ | `--print` | Write to stdout instead of the clipboard |
97
+ | `-o, --output FILE` | Also write the plain text to a file |
98
+ | `--soft-breaks` | Treat single newlines as spaces |
99
+ | `--version` | Print the version and exit |
100
+
101
+ ## Output format
102
+
103
+ | Markdown | Output |
104
+ |---|---|
105
+ | `# Heading` | heading + `====` underline |
106
+ | `## Heading` | heading + `----` underline |
107
+ | `### Heading` | heading + `~~~~` underline |
108
+ | Paragraph | hard-wrapped at 75 columns (CJK counts as two) |
109
+ | `- item` | `* item`, with hanging indent |
110
+ | `1. item` | `1. item` |
111
+ | `> quote` | `> quote` |
112
+ | Code block | indented by four spaces |
113
+ | `[text](url)` | `text (url)` |
114
+ | Table | column-aligned plain text |
115
+ | Single newline | kept (signatures and dates stay on their own lines) |
116
+
117
+ ## How it works
118
+
119
+ 1. `markdown-it-py` parses CommonMark plus tables, strikethrough, and
120
+ autolinks.
121
+ 2. `src/md2plain/render.py` wraps text by display width (CJK counts as two
122
+ columns), renders headings, lists, quotes, and tables.
123
+ 3. `src/md2plain/cli.py` reads UTF-8 (BOM tolerant) and writes exactly one
124
+ clipboard type: `text/plain`.
125
+
126
+ ## Limitations
127
+
128
+ - macOS only; the clipboard backend is AppKit.
129
+ - Plain text has no bold, italics, or clickable links. Link URLs are kept as
130
+ text, and links inside headings or table cells lose the URL.
131
+ - Images become alt text. YAML front matter, footnotes, math, and Mermaid
132
+ diagrams are not converted.
133
+ - Tables are aligned with spaces, so proportional fonts break the alignment.
134
+ - Gmail may collapse `>` quote blocks behind a three-dot control; this is
135
+ display only and the content stays intact.
136
+
137
+ ## Development
138
+
139
+ ```bash
140
+ python3 -m venv .venv
141
+ .venv/bin/pip install -e ".[dev]"
142
+ .venv/bin/python -m unittest discover -s tests -v
143
+ .venv/bin/ruff check src tests
144
+ .venv/bin/black --check src tests
145
+ ```
146
+
147
+ The `./md2plain` script in the repository root is a development launcher; it
148
+ runs the package from `.venv`. Installed users get the `md2plain` command from
149
+ `pyproject.toml`.
150
+
151
+ ## License
152
+
153
+ [MIT](LICENSE). See [CHANGELOG.md](CHANGELOG.md) for release history.
@@ -0,0 +1,8 @@
1
+ #!/bin/sh
2
+ # Development launcher. Install with `pipx install .` for the real command.
3
+ DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
4
+ if [ ! -x "$DIR/.venv/bin/python" ]; then
5
+ echo "Missing .venv. Run: python3 -m venv .venv && .venv/bin/pip install -e ." >&2
6
+ exit 1
7
+ fi
8
+ exec "$DIR/.venv/bin/python" -m md2plain "$@"
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "md2plain"
7
+ dynamic = ["version"]
8
+ description = "Convert Markdown to plain-text email for Gmail's Plain text mode."
9
+ readme = "README.md"
10
+ license = { file = "LICENSE" }
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "hoshF", email = "noveor.ol@gmail.com" }]
13
+ keywords = ["markdown", "plain-text", "email", "gmail", "cli"]
14
+ classifiers = [
15
+ "Environment :: Console",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: MacOS",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Communications :: Email",
23
+ "Topic :: Text Processing :: Markup",
24
+ ]
25
+ dependencies = [
26
+ "markdown-it-py>=3.0",
27
+ "linkify-it-py>=2.0",
28
+ "pyobjc-framework-Cocoa>=10.0; sys_platform == 'darwin'",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ dev = ["black>=24", "ruff>=0.6"]
33
+
34
+ [project.scripts]
35
+ md2plain = "md2plain.cli:main"
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/hoshF/md2plain"
39
+ Issues = "https://github.com/hoshF/md2plain/issues"
40
+
41
+ [tool.hatch.version]
42
+ path = "src/md2plain/__init__.py"
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["src/md2plain"]
46
+
47
+ [tool.black]
48
+ line-length = 88
49
+
50
+ [tool.ruff]
51
+ line-length = 88
@@ -0,0 +1,3 @@
1
+ """Convert Markdown into plain-text email."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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())
@@ -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,455 @@
1
+ """Tests for md2plain: renderer, CLI, clipboard and output invariants."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ import time
10
+ import unittest
11
+ from pathlib import Path
12
+
13
+ from md2plain.render import disp_width, render_plain
14
+
15
+ ROOT = Path(__file__).resolve().parents[1]
16
+ CLI = [sys.executable, "-m", "md2plain"]
17
+ MACOS = sys.platform == "darwin"
18
+
19
+ SAMPLE_MD = """\
20
+ # Format Test
21
+
22
+ Dear Tester,
23
+
24
+ This is a sample letter with **bold text**, *italics* and `inline code`.
25
+ It also has a [link](https://example.com) and a bare URL https://example.org.
26
+
27
+ ## A List
28
+
29
+ * first item
30
+ * second item, long enough to wrap onto another line when the width is small
31
+ * third item
32
+
33
+ 1. one
34
+ 2. two
35
+
36
+ ## A Quote
37
+
38
+ > quoted line one
39
+ > quoted line two
40
+
41
+ ## A Table
42
+
43
+ | name | count |
44
+ | --- | --- |
45
+ | apple | 3 |
46
+ | pear | 12 |
47
+
48
+ ## Code
49
+
50
+ ```python
51
+ print("hello")
52
+ ```
53
+
54
+ ---
55
+
56
+ Best regards,
57
+ Test User
58
+ 2026-01-01
59
+ """
60
+
61
+
62
+ def r(md: str, width: int = 75, preserve: bool = True) -> str:
63
+ return render_plain(md, width, preserve)
64
+
65
+
66
+ def run_cli(*args: str, stdin: str | None = None) -> subprocess.CompletedProcess:
67
+ return subprocess.run(
68
+ CLI + list(args),
69
+ input=stdin,
70
+ text=True,
71
+ capture_output=True,
72
+ cwd=ROOT,
73
+ check=False,
74
+ )
75
+
76
+
77
+ class SampleCase(unittest.TestCase):
78
+ """Provide a temporary Markdown file for CLI tests."""
79
+
80
+ def setUp(self) -> None:
81
+ self.tmp = tempfile.TemporaryDirectory()
82
+ self.addCleanup(self.tmp.cleanup)
83
+ self.sample = Path(self.tmp.name) / "sample.md"
84
+ self.sample.write_text(SAMPLE_MD, encoding="utf-8")
85
+
86
+
87
+ class Headings(unittest.TestCase):
88
+ def test_levels_use_underline(self):
89
+ out = r(
90
+ "# One\n\n## Two\n\n### Three\n\n#### Four\n\n##### Five\n\n###### Six\n"
91
+ )
92
+ self.assertIn("One\n===", out)
93
+ self.assertIn("Two\n---", out)
94
+ self.assertIn("Three\n~~~~~", out)
95
+ self.assertIn("Four\n^^^^", out)
96
+ self.assertIn('Five\n""""', out)
97
+ self.assertIn("Six\n'''", out)
98
+
99
+ def test_underline_matches_cjk_width(self):
100
+ self.assertEqual(r("# 标题\n"), "标题\n====\n")
101
+
102
+ def test_empty_heading_skipped(self):
103
+ self.assertEqual(r("#\n\ntext\n"), "text\n")
104
+
105
+
106
+ class Paragraphs(unittest.TestCase):
107
+ def test_wrap_respects_width(self):
108
+ out = r("word " * 60, 75)
109
+ for line in out.splitlines():
110
+ self.assertLessEqual(disp_width(line), 75)
111
+
112
+ def test_cjk_wraps_by_display_width(self):
113
+ out = r("这是一段很长的中文文字,用于测试按两列宽度换行。" * 5, 40)
114
+ for line in out.splitlines():
115
+ self.assertLessEqual(disp_width(line), 40)
116
+
117
+ def test_no_closing_punctuation_at_line_start(self):
118
+ out = r("字" * 74 + "。下一句。", 75)
119
+ for line in out.splitlines():
120
+ self.assertFalse(line.startswith("。"))
121
+
122
+ def test_single_newline_is_kept(self):
123
+ self.assertEqual(r("Dear A,\nBest regards\n"), "Dear A,\nBest regards\n")
124
+
125
+ def test_soft_breaks_join_lines(self):
126
+ self.assertEqual(r("a\nb\n", 75, preserve=False), "a b\n")
127
+
128
+ def test_hard_break_two_spaces(self):
129
+ self.assertEqual(r("a \nb\n"), "a\nb\n")
130
+
131
+ def test_hard_break_backslash(self):
132
+ self.assertEqual(r("a\\\nb\n"), "a\nb\n")
133
+
134
+ def test_tabs_collapse(self):
135
+ self.assertEqual(r("a\tb\n"), "a b\n")
136
+
137
+ def test_long_token_is_split(self):
138
+ out = r("x" * 120, 40)
139
+ self.assertEqual([len(line) for line in out.splitlines()], [40, 40, 40])
140
+
141
+
142
+ class Lists(unittest.TestCase):
143
+ def test_bullets(self):
144
+ self.assertEqual(r("- a\n- b\n"), "* a\n* b\n")
145
+
146
+ def test_ordered(self):
147
+ self.assertEqual(r("1. a\n2. b\n"), "1. a\n2. b\n")
148
+
149
+ def test_ordered_start(self):
150
+ self.assertEqual(r("3. c\n4. d\n"), "3. c\n4. d\n")
151
+
152
+ def test_nested_is_tight(self):
153
+ self.assertEqual(r("- a\n - b\n - c\n- d\n"), "* a\n * b\n * c\n* d\n")
154
+
155
+ def test_hanging_indent(self):
156
+ out = r("- " + "word " * 30 + "\n", 40)
157
+ self.assertTrue(out.startswith("* word"))
158
+ self.assertTrue(out.splitlines()[1].startswith(" "))
159
+
160
+
161
+ class Quotes(unittest.TestCase):
162
+ def test_simple(self):
163
+ self.assertEqual(r("> a\n> b\n"), "> a\n> b\n")
164
+
165
+ def test_blank_line(self):
166
+ self.assertEqual(r("> a\n>\n> b\n"), "> a\n>\n> b\n")
167
+
168
+ def test_nested(self):
169
+ self.assertEqual(r("> a\n> > b\n"), "> a\n>\n> > b\n")
170
+
171
+ def test_respects_width(self):
172
+ out = r("> " + "word " * 40, 40)
173
+ for line in out.splitlines():
174
+ self.assertLessEqual(disp_width(line), 40)
175
+
176
+
177
+ class Code(unittest.TestCase):
178
+ def test_fence_indented(self):
179
+ self.assertEqual(r("```\nx = 1\n```\n"), " x = 1\n")
180
+
181
+ def test_fence_not_wrapped(self):
182
+ long_line = "x" * 100
183
+ self.assertIn(" " + long_line, r(f"```\n{long_line}\n```\n"))
184
+
185
+ def test_inline_code(self):
186
+ self.assertEqual(r("use `x` here\n"), "use x here\n")
187
+
188
+
189
+ class Tables(unittest.TestCase):
190
+ def test_alignment(self):
191
+ out = r("| a | b |\n| --- | --- |\n| 1 | 2 |\n")
192
+ self.assertEqual(out, "a b\n- -\n1 2\n")
193
+
194
+ def test_cjk_columns(self):
195
+ out = r("| 名称 | n |\n| --- | --- |\n| 苹果 | 3 |\n")
196
+ lines = out.splitlines()
197
+ self.assertEqual(lines[0], "名称 n")
198
+ self.assertEqual(lines[1], "---- -")
199
+
200
+
201
+ class Inline(unittest.TestCase):
202
+ def test_markdown_link(self):
203
+ self.assertEqual(r("[x](https://e.com)\n"), "x (https://e.com)\n")
204
+
205
+ def test_url_label_with_tracking_param(self):
206
+ self.assertEqual(
207
+ r("[https://e.com](https://e.com?utm_source=x)\n"), "https://e.com\n"
208
+ )
209
+
210
+ def test_bare_url_not_duplicated(self):
211
+ self.assertEqual(r("https://e.com\n"), "https://e.com\n")
212
+
213
+ def test_mailto_not_duplicated(self):
214
+ self.assertEqual(r("<a@b.com>\n"), "a@b.com\n")
215
+
216
+ def test_unsafe_scheme_not_linked(self):
217
+ self.assertEqual(r("[x](javascript:alert(1))\n"), "[x](javascript:alert(1))\n")
218
+
219
+ def test_image_alt(self):
220
+ self.assertEqual(r("![alt text](i.png)\n"), "alt text\n")
221
+
222
+ def test_emphasis_stripped(self):
223
+ self.assertEqual(r("**bold** *it* ~~gone~~\n"), "bold it gone\n")
224
+
225
+ def test_html_is_literal(self):
226
+ self.assertEqual(r("<b>x</b>\n"), "<b>x</b>\n")
227
+
228
+
229
+ class Blocks(unittest.TestCase):
230
+ def test_hr(self):
231
+ self.assertEqual(r("---\n"), "-" * 72 + "\n")
232
+
233
+ def test_empty_input(self):
234
+ self.assertEqual(r(""), "\n")
235
+
236
+
237
+ class Widths(unittest.TestCase):
238
+ def test_no_wrap_when_width_is_large(self):
239
+ out = r("word " * 40, 1000)
240
+ self.assertEqual(len(out.splitlines()), 1)
241
+
242
+ def test_width_one_has_no_blank_lines(self):
243
+ out = r("a b c\n", 1)
244
+ self.assertEqual([line for line in out.splitlines() if not line], [])
245
+
246
+
247
+ class Cli(SampleCase):
248
+ def test_print_matches_renderer(self):
249
+ proc = run_cli(str(self.sample), "--print", "--no-copy")
250
+ self.assertEqual(proc.returncode, 0)
251
+ self.assertEqual(proc.stdout, render_plain(SAMPLE_MD))
252
+
253
+ def test_output_file(self):
254
+ out = Path(self.tmp.name) / "out.txt"
255
+ proc = run_cli(str(self.sample), "--no-copy", "-o", str(out))
256
+ self.assertEqual(proc.returncode, 0)
257
+ self.assertEqual(out.read_text(encoding="utf-8"), render_plain(SAMPLE_MD))
258
+
259
+ def test_stdin(self):
260
+ proc = run_cli("-", "--print", "--no-copy", stdin="# Hi\n")
261
+ self.assertEqual(proc.stdout, "Hi\n===\n")
262
+
263
+ def test_missing_file(self):
264
+ proc = run_cli("nope.md", "--no-copy")
265
+ self.assertEqual(proc.returncode, 1)
266
+ self.assertIn("File not found", proc.stderr)
267
+
268
+ def test_empty_input(self):
269
+ proc = run_cli("-", "--no-copy", stdin="")
270
+ self.assertEqual(proc.returncode, 1)
271
+ self.assertIn("Input is empty", proc.stderr)
272
+
273
+ def test_invalid_width(self):
274
+ proc = run_cli(str(self.sample), "--width", "0", "--no-copy")
275
+ self.assertEqual(proc.returncode, 2)
276
+ self.assertIn("at least 1", proc.stderr)
277
+
278
+ def test_watch_requires_file(self):
279
+ proc = run_cli("-", "--watch", stdin="x")
280
+ self.assertEqual(proc.returncode, 2)
281
+ self.assertIn("requires a file", proc.stderr)
282
+
283
+ def test_bom_stripped(self):
284
+ path = Path(self.tmp.name) / "bom.md"
285
+ path.write_bytes(b"\xef\xbb\xbf# Title\n\nBody\n")
286
+ proc = run_cli(str(path), "--print", "--no-copy")
287
+ self.assertFalse(proc.stdout.startswith("\ufeff"))
288
+ self.assertTrue(proc.stdout.startswith("Title"))
289
+
290
+ def test_crlf(self):
291
+ path = Path(self.tmp.name) / "crlf.md"
292
+ path.write_bytes(b"a\r\nb\r\n")
293
+ proc = run_cli(str(path), "--print", "--no-copy")
294
+ self.assertEqual(proc.stdout, "a\nb\n")
295
+
296
+ def test_invalid_utf8(self):
297
+ path = Path(self.tmp.name) / "bad.md"
298
+ path.write_bytes(b"hello \xff world\n")
299
+ proc = run_cli(str(path), "--print", "--no-copy")
300
+ self.assertEqual(proc.returncode, 0)
301
+
302
+ def test_directory_input(self):
303
+ proc = run_cli(self.tmp.name, "--no-copy")
304
+ self.assertEqual(proc.returncode, 1)
305
+ self.assertIn("Cannot read", proc.stderr)
306
+
307
+ def test_path_with_spaces(self):
308
+ path = Path(self.tmp.name) / "a b.md"
309
+ path.write_text("# Hi\n", encoding="utf-8")
310
+ proc = run_cli(str(path), "--print", "--no-copy")
311
+ self.assertEqual(proc.stdout, "Hi\n===\n")
312
+
313
+ def test_cli_width_matches_renderer(self):
314
+ proc = run_cli(str(self.sample), "--print", "--no-copy", "--width", "60")
315
+ self.assertEqual(proc.stdout, render_plain(SAMPLE_MD, 60))
316
+
317
+ def test_output_path_with_spaces(self):
318
+ out = Path(self.tmp.name) / "out file.txt"
319
+ proc = run_cli(str(self.sample), "--no-copy", "-o", str(out))
320
+ self.assertEqual(proc.returncode, 0)
321
+ self.assertTrue(out.exists())
322
+
323
+
324
+ class Invariants(unittest.TestCase):
325
+ def check(self, out: str) -> None:
326
+ self.assertTrue(out.endswith("\n"))
327
+ self.assertFalse(out.endswith("\n\n"))
328
+ self.assertNotIn("\x00", out)
329
+ self.assertNotIn("\r", out)
330
+ for line in out.splitlines():
331
+ self.assertEqual(line, line.rstrip())
332
+ self.assertLessEqual(disp_width(line), 76)
333
+ self.assertFalse(line[:1] in set("。,、;:!?"))
334
+
335
+ def test_sample(self):
336
+ self.check(r(SAMPLE_MD))
337
+
338
+ def test_cjk(self):
339
+ self.check(r("中文段落。" * 40))
340
+
341
+ def test_deterministic(self):
342
+ self.assertEqual(r(SAMPLE_MD), r(SAMPLE_MD))
343
+
344
+
345
+ class Performance(unittest.TestCase):
346
+ def test_large_document(self):
347
+ md = ("word " * 20000 + "\n\n") + ("中文段落。" * 5000)
348
+ start = time.perf_counter()
349
+ out = r(md)
350
+ elapsed = time.perf_counter() - start
351
+ self.assertGreater(len(out), 10000)
352
+ self.assertLess(elapsed, 2.0)
353
+
354
+
355
+ class Fuzz(unittest.TestCase):
356
+ def test_random_markdown_never_crashes(self):
357
+ import random
358
+
359
+ random.seed(7)
360
+ parts = [
361
+ "# Title",
362
+ "## Sub",
363
+ "text",
364
+ "中文段落",
365
+ "- item",
366
+ "1. one",
367
+ "> quote",
368
+ "```",
369
+ "code()",
370
+ "| a | b |",
371
+ "| --- | --- |",
372
+ "**bold**",
373
+ "[link](https://e.com)",
374
+ "https://e.com",
375
+ "---",
376
+ "",
377
+ " ",
378
+ "\t",
379
+ "😀",
380
+ "a" * 90,
381
+ ]
382
+ for _ in range(200):
383
+ md = "\n".join(random.choice(parts) for _ in range(random.randint(1, 25)))
384
+ out = r(md + "\n", random.choice([1, 10, 40, 75, 200]))
385
+ self.assertTrue(out.endswith("\n"))
386
+ self.assertNotIn("\x00", out)
387
+ self.assertNotIn("\r", out)
388
+ for line in out.splitlines():
389
+ self.assertEqual(line, line.rstrip())
390
+
391
+
392
+ @unittest.skipUnless(MACOS, "clipboard tests need macOS")
393
+ class Clipboard(SampleCase):
394
+ def test_text_only_and_content(self):
395
+ from AppKit import NSPasteboard, NSPasteboardTypeHTML, NSPasteboardTypeString
396
+
397
+ name = f"md2plain-test-{os.getpid()}"
398
+ env = dict(os.environ, MD2PLAIN_PASTEBOARD=name)
399
+ proc = subprocess.run(
400
+ CLI + [str(self.sample)],
401
+ env=env,
402
+ text=True,
403
+ capture_output=True,
404
+ cwd=ROOT,
405
+ check=False,
406
+ )
407
+ self.assertEqual(proc.returncode, 0)
408
+ board = NSPasteboard.pasteboardWithName_(name)
409
+ self.assertIsNone(board.dataForType_(NSPasteboardTypeHTML))
410
+ self.assertEqual(
411
+ board.stringForType_(NSPasteboardTypeString), render_plain(SAMPLE_MD)
412
+ )
413
+
414
+
415
+ @unittest.skipUnless(MACOS, "clipboard tests need macOS")
416
+ class Watch(unittest.TestCase):
417
+ def test_watch_updates_clipboard(self):
418
+ from AppKit import NSPasteboard, NSPasteboardTypeString
419
+
420
+ name = f"md2plain-watch-{os.getpid()}"
421
+ with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as f:
422
+ f.write("# A\n\nfirst\n")
423
+ path = f.name
424
+ env = dict(os.environ, MD2PLAIN_PASTEBOARD=name)
425
+ proc = subprocess.Popen(
426
+ CLI + [path, "--watch"],
427
+ env=env,
428
+ cwd=ROOT,
429
+ stdout=subprocess.DEVNULL,
430
+ stderr=subprocess.DEVNULL,
431
+ )
432
+ board = NSPasteboard.pasteboardWithName_(name)
433
+ try:
434
+ deadline = time.time() + 6
435
+ while time.time() < deadline:
436
+ if "A" in (board.stringForType_(NSPasteboardTypeString) or ""):
437
+ break
438
+ time.sleep(0.2)
439
+ Path(path).write_text("# B\n\nsecond\n", encoding="utf-8")
440
+ deadline = time.time() + 6
441
+ text = ""
442
+ while time.time() < deadline:
443
+ text = board.stringForType_(NSPasteboardTypeString) or ""
444
+ if "B" in text:
445
+ break
446
+ time.sleep(0.2)
447
+ self.assertIn("B", text)
448
+ finally:
449
+ proc.terminate()
450
+ proc.wait(timeout=5)
451
+ os.unlink(path)
452
+
453
+
454
+ if __name__ == "__main__":
455
+ unittest.main(verbosity=2)