pmd-cli 1.0.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.
- pmd/__init__.py +3 -0
- pmd/__main__.py +854 -0
- pmd_cli-1.0.0.dist-info/METADATA +175 -0
- pmd_cli-1.0.0.dist-info/RECORD +7 -0
- pmd_cli-1.0.0.dist-info/WHEEL +5 -0
- pmd_cli-1.0.0.dist-info/entry_points.txt +2 -0
- pmd_cli-1.0.0.dist-info/top_level.txt +1 -0
pmd/__init__.py
ADDED
pmd/__main__.py
ADDED
|
@@ -0,0 +1,854 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""pmd - Terminal Markdown renderer with ANSI formatting.
|
|
3
|
+
|
|
4
|
+
Zero dependencies, Python 3 stdlib only.
|
|
5
|
+
Usage: pmd <file.md> or cat file.md | pmd
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import textwrap
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
16
|
+
# ANSI Styles
|
|
17
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
18
|
+
|
|
19
|
+
def _s(*codes):
|
|
20
|
+
"""Build an ANSI SGR sequence from attribute codes."""
|
|
21
|
+
return '\033[' + ';'.join(str(c) for c in codes) + 'm'
|
|
22
|
+
|
|
23
|
+
RESET = _s(0)
|
|
24
|
+
|
|
25
|
+
# Heading styles — descending visual weight
|
|
26
|
+
STYLE = {
|
|
27
|
+
'h1': _s(1, 37, 45), # bold white on magenta
|
|
28
|
+
'h2': _s(1, 37, 44), # bold white on blue
|
|
29
|
+
'h3': _s(1, 30, 46), # bold black on cyan
|
|
30
|
+
'h4': _s(1, 32), # bold green (no bg)
|
|
31
|
+
'h5': _s(1, 33), # bold yellow
|
|
32
|
+
'h6': _s(2, 37), # dim white
|
|
33
|
+
'bold': _s(1, 31), # bold red
|
|
34
|
+
'italic': _s(3),
|
|
35
|
+
'bold_italic': _s(1, 3, 31), # bold italic red
|
|
36
|
+
'strikethrough': _s(9),
|
|
37
|
+
'code': _s(1, 32), # bold green
|
|
38
|
+
'link': _s(4, 34), # underline blue
|
|
39
|
+
'image': _s(2, 35), # dim magenta
|
|
40
|
+
'bq_bar': _s(36), # cyan blockquote bar
|
|
41
|
+
'bq_text': _s(2), # dim blockquote text
|
|
42
|
+
'table_head': _s(1, 4), # bold underline
|
|
43
|
+
'table_border': _s(2), # dim borders
|
|
44
|
+
'hr': _s(2), # dim horizontal rule
|
|
45
|
+
'bullet': _s(36), # cyan bullet
|
|
46
|
+
'list_num': _s(33), # yellow numbers
|
|
47
|
+
'dim': _s(2),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
def style(text, name):
|
|
51
|
+
"""Wrap text in an ANSI style."""
|
|
52
|
+
s = STYLE.get(name)
|
|
53
|
+
return f'{s}{text}{RESET}' if s else text
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
57
|
+
# Inline Parser
|
|
58
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
59
|
+
|
|
60
|
+
# Regex matching all inline elements; order determines priority.
|
|
61
|
+
_INLINE_RE = re.compile(
|
|
62
|
+
r'`(?P<code>[^`\n]+)`'
|
|
63
|
+
r'|!\[(?P<image_alt>[^\]]*)\]\((?P<image_url>[^)]*)\)'
|
|
64
|
+
r'|\[(?P<link_text>[^\]]*)\]\((?P<link_url>[^)]*)\)'
|
|
65
|
+
r'|\*\*\*(?P<bold_italic>.+?)\*\*\*'
|
|
66
|
+
r'|\*\*(?P<bold>.+?)\*\*'
|
|
67
|
+
r'|(?<!\*)\*(?!\*)(?P<italic>.+?)(?<!\*)\*(?!\*)'
|
|
68
|
+
r'|(?<=\b)__(?=\S)(?P<bold2>.+?)(?<=\S)__(?=\b)'
|
|
69
|
+
r'|(?<=\b)_(?!_)(?=\S)(?P<italic2>.+?)(?<=\S)(?<!_)_(?=\b)'
|
|
70
|
+
r'|~~(?P<strike>.+?)~~'
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parse_inline(text):
|
|
75
|
+
"""Parse inline Markdown into a list of (type, text, url) tuples.
|
|
76
|
+
Uses a two-pass approach: code spans first, then remaining formatting.
|
|
77
|
+
This ensures `` `code` `` inside `**bold**` is rendered correctly.
|
|
78
|
+
"""
|
|
79
|
+
if not text:
|
|
80
|
+
return []
|
|
81
|
+
|
|
82
|
+
# Pass 1: pre-render code spans and replace with rendered ANSI text.
|
|
83
|
+
# This ensures `` `code` `` inside `**bold**` renders with both styles.
|
|
84
|
+
code_re = re.compile(r'`(?P<code>[^`\n]+)`')
|
|
85
|
+
|
|
86
|
+
def render_code(m):
|
|
87
|
+
return style(m.group('code'), 'code')
|
|
88
|
+
|
|
89
|
+
text = code_re.sub(render_code, text)
|
|
90
|
+
|
|
91
|
+
# Pass 2: parse remaining formatting on the text with pre-rendered code
|
|
92
|
+
tokens = []
|
|
93
|
+
pos = 0
|
|
94
|
+
|
|
95
|
+
for m in _INLINE_RE.finditer(text):
|
|
96
|
+
if m.start() > pos:
|
|
97
|
+
tokens.append(('text', text[pos:m.start()], ''))
|
|
98
|
+
|
|
99
|
+
kind = m.lastgroup
|
|
100
|
+
if kind in ('image_alt', 'image_url'):
|
|
101
|
+
tokens.append(('image', m.group('image_alt') or m.group('image_url'), m.group('image_url')))
|
|
102
|
+
elif kind in ('link_text', 'link_url'):
|
|
103
|
+
tokens.append(('link', m.group('link_text'), m.group('link_url')))
|
|
104
|
+
elif kind == 'bold_italic':
|
|
105
|
+
tokens.append(('bold_italic', m.group('bold_italic'), ''))
|
|
106
|
+
elif kind in ('bold', 'bold2'):
|
|
107
|
+
tokens.append(('bold', m.group(kind), ''))
|
|
108
|
+
elif kind in ('italic', 'italic2'):
|
|
109
|
+
tokens.append(('italic', m.group(kind), ''))
|
|
110
|
+
elif kind == 'strike':
|
|
111
|
+
tokens.append(('strikethrough', m.group('strike'), ''))
|
|
112
|
+
|
|
113
|
+
pos = m.end()
|
|
114
|
+
|
|
115
|
+
if pos < len(text):
|
|
116
|
+
tokens.append(('text', text[pos:], ''))
|
|
117
|
+
|
|
118
|
+
return tokens
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
122
|
+
# Block Parser
|
|
123
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
124
|
+
|
|
125
|
+
HEADING_RE = re.compile(r'^(#{1,6})\s+(.*)')
|
|
126
|
+
FENCE_RE = re.compile(r'^```(\w*)\s*$')
|
|
127
|
+
BQ_RE = re.compile(r'^>\s?(.*)')
|
|
128
|
+
UL_RE = re.compile(r'^(\s*)([-*+])\s+(.*)')
|
|
129
|
+
OL_RE = re.compile(r'^(\s*)(\d+)[.)]\s+(.*)')
|
|
130
|
+
HR_RE = re.compile(r'^(?:[-*_]\s?){3,}$')
|
|
131
|
+
TABLE_ROW_RE = re.compile(r'^\|(.+)\|$')
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def parse_blocks(text):
|
|
135
|
+
"""Parse Markdown text into a list of block dicts."""
|
|
136
|
+
lines = text.split('\n')
|
|
137
|
+
blocks = []
|
|
138
|
+
i = 0
|
|
139
|
+
n = len(lines)
|
|
140
|
+
|
|
141
|
+
while i < n:
|
|
142
|
+
# Skip blank lines
|
|
143
|
+
if lines[i].strip() == '':
|
|
144
|
+
i += 1
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
line = lines[i]
|
|
148
|
+
|
|
149
|
+
# Heading
|
|
150
|
+
m = HEADING_RE.match(line)
|
|
151
|
+
if m:
|
|
152
|
+
blocks.append({
|
|
153
|
+
'type': 'heading',
|
|
154
|
+
'level': len(m.group(1)),
|
|
155
|
+
'content': m.group(2),
|
|
156
|
+
})
|
|
157
|
+
i += 1
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
# Horizontal rule (only if not part of a table)
|
|
161
|
+
hr_match = HR_RE.match(line.strip())
|
|
162
|
+
if hr_match and '|' not in line.strip():
|
|
163
|
+
blocks.append({'type': 'hr'})
|
|
164
|
+
i += 1
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
# Fenced code block
|
|
168
|
+
m = FENCE_RE.match(line.strip())
|
|
169
|
+
if m:
|
|
170
|
+
lang = m.group(1)
|
|
171
|
+
i += 1
|
|
172
|
+
code_lines = []
|
|
173
|
+
while i < n and not FENCE_RE.match(lines[i].strip()):
|
|
174
|
+
code_lines.append(lines[i])
|
|
175
|
+
i += 1
|
|
176
|
+
if i < n:
|
|
177
|
+
i += 1 # closing fence
|
|
178
|
+
blocks.append({
|
|
179
|
+
'type': 'code_block',
|
|
180
|
+
'language': lang,
|
|
181
|
+
'lines': code_lines,
|
|
182
|
+
})
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
# Blockquote
|
|
186
|
+
m = BQ_RE.match(line)
|
|
187
|
+
if m:
|
|
188
|
+
bq_lines = []
|
|
189
|
+
while i < n:
|
|
190
|
+
m2 = BQ_RE.match(lines[i])
|
|
191
|
+
if m2:
|
|
192
|
+
bq_lines.append(m2.group(1))
|
|
193
|
+
i += 1
|
|
194
|
+
elif lines[i].strip() == '':
|
|
195
|
+
# Check if next non-blank continues blockquote
|
|
196
|
+
j = i + 1
|
|
197
|
+
while j < n and lines[j].strip() == '':
|
|
198
|
+
j += 1
|
|
199
|
+
if j < n and BQ_RE.match(lines[j]):
|
|
200
|
+
bq_lines.append('')
|
|
201
|
+
i += 1
|
|
202
|
+
else:
|
|
203
|
+
break
|
|
204
|
+
else:
|
|
205
|
+
break
|
|
206
|
+
blocks.append({'type': 'blockquote', 'lines': bq_lines})
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
# Table
|
|
210
|
+
if TABLE_ROW_RE.match(line.strip()):
|
|
211
|
+
table_rows = []
|
|
212
|
+
while i < n and TABLE_ROW_RE.match(lines[i].strip()):
|
|
213
|
+
table_rows.append(_parse_table_row(lines[i].strip()))
|
|
214
|
+
i += 1
|
|
215
|
+
if len(table_rows) >= 2:
|
|
216
|
+
blocks.append(_build_table(table_rows))
|
|
217
|
+
continue
|
|
218
|
+
else:
|
|
219
|
+
# Not enough rows, treat as paragraph
|
|
220
|
+
blocks.append({
|
|
221
|
+
'type': 'paragraph',
|
|
222
|
+
'content': ' '.join(' '.join(r) for r in table_rows),
|
|
223
|
+
})
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
# Unordered list
|
|
227
|
+
m = UL_RE.match(line)
|
|
228
|
+
if m:
|
|
229
|
+
items = _parse_list_items(lines, i, n, ordered=False)
|
|
230
|
+
i += sum(len(it['raw_lines']) for it in items)
|
|
231
|
+
blocks.append({
|
|
232
|
+
'type': 'list',
|
|
233
|
+
'ordered': False,
|
|
234
|
+
'start': 1,
|
|
235
|
+
'items': items,
|
|
236
|
+
})
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
# Ordered list
|
|
240
|
+
m = OL_RE.match(line)
|
|
241
|
+
if m:
|
|
242
|
+
items = _parse_list_items(lines, i, n, ordered=True)
|
|
243
|
+
i += sum(len(it['raw_lines']) for it in items)
|
|
244
|
+
start = int(OL_RE.match(items[0]['raw_lines'][0]).group(2)) if items else 1
|
|
245
|
+
blocks.append({
|
|
246
|
+
'type': 'list',
|
|
247
|
+
'ordered': True,
|
|
248
|
+
'start': start,
|
|
249
|
+
'items': items,
|
|
250
|
+
})
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
# Paragraph: collect consecutive text lines
|
|
254
|
+
para_lines = []
|
|
255
|
+
while i < n and lines[i].strip() != '':
|
|
256
|
+
ln = lines[i]
|
|
257
|
+
if (HEADING_RE.match(ln) or FENCE_RE.match(ln.strip()) or
|
|
258
|
+
BQ_RE.match(ln) or UL_RE.match(ln) or OL_RE.match(ln) or
|
|
259
|
+
TABLE_ROW_RE.match(ln.strip()) or
|
|
260
|
+
(HR_RE.match(ln.strip()) and '|' not in ln.strip())):
|
|
261
|
+
break
|
|
262
|
+
para_lines.append(ln)
|
|
263
|
+
i += 1
|
|
264
|
+
|
|
265
|
+
if para_lines:
|
|
266
|
+
blocks.append({
|
|
267
|
+
'type': 'paragraph',
|
|
268
|
+
'content': ' '.join(para_lines),
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
return blocks
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _parse_table_row(line):
|
|
275
|
+
"""Split a |...| table row into cells."""
|
|
276
|
+
inner = line.strip()[1:-1] # remove leading | and trailing |
|
|
277
|
+
return [c.strip() for c in inner.split('|')]
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _is_table_sep(cell):
|
|
281
|
+
"""Check if a cell is a separator like ---, :---, :---:"""
|
|
282
|
+
c = cell.strip()
|
|
283
|
+
c = c.lstrip(':').rstrip(':')
|
|
284
|
+
return len(c) > 0 and all(ch == '-' for ch in c)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _build_table(rows):
|
|
288
|
+
"""Build a table block from raw rows, detecting separator and alignments."""
|
|
289
|
+
# Find separator row
|
|
290
|
+
sep_idx = None
|
|
291
|
+
for idx, row in enumerate(rows):
|
|
292
|
+
if all(_is_table_sep(c) for c in row):
|
|
293
|
+
sep_idx = idx
|
|
294
|
+
break
|
|
295
|
+
|
|
296
|
+
if sep_idx is None:
|
|
297
|
+
return {'type': 'paragraph', 'content': ' '.join(' | '.join(r) for r in rows)}
|
|
298
|
+
|
|
299
|
+
# Parse alignments
|
|
300
|
+
aligns = []
|
|
301
|
+
for cell in rows[sep_idx]:
|
|
302
|
+
c = cell.strip()
|
|
303
|
+
left = c.startswith(':')
|
|
304
|
+
right = c.endswith(':')
|
|
305
|
+
if left and right:
|
|
306
|
+
aligns.append('center')
|
|
307
|
+
elif right:
|
|
308
|
+
aligns.append('right')
|
|
309
|
+
else:
|
|
310
|
+
aligns.append('left')
|
|
311
|
+
|
|
312
|
+
# Header + data rows
|
|
313
|
+
header = rows[:sep_idx]
|
|
314
|
+
data = rows[sep_idx + 1:]
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
'type': 'table',
|
|
318
|
+
'header': header,
|
|
319
|
+
'rows': data,
|
|
320
|
+
'aligns': aligns,
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _parse_list_items(lines, start, end, ordered=False):
|
|
325
|
+
"""Parse consecutive list items starting at line `start`."""
|
|
326
|
+
item_re = OL_RE if ordered else UL_RE
|
|
327
|
+
items = []
|
|
328
|
+
i = start
|
|
329
|
+
|
|
330
|
+
while i < end:
|
|
331
|
+
line = lines[i]
|
|
332
|
+
if line.strip() == '':
|
|
333
|
+
i += 1
|
|
334
|
+
continue
|
|
335
|
+
|
|
336
|
+
m = item_re.match(line)
|
|
337
|
+
if not m:
|
|
338
|
+
break
|
|
339
|
+
|
|
340
|
+
raw_content = m.group(3)
|
|
341
|
+
todo = False
|
|
342
|
+
done = False
|
|
343
|
+
# GFM todo: - [ ] item or - [x] item
|
|
344
|
+
if re.match(r'\[ \]\s+', raw_content):
|
|
345
|
+
todo, done = True, False
|
|
346
|
+
raw_content = re.sub(r'^\[ \]\s+', '', raw_content)
|
|
347
|
+
elif re.match(r'\[[xX]\]\s+', raw_content):
|
|
348
|
+
todo, done = True, True
|
|
349
|
+
raw_content = re.sub(r'^\[[xX]\]\s+', '', raw_content)
|
|
350
|
+
|
|
351
|
+
raw_lines = [line]
|
|
352
|
+
content_lines = [raw_content]
|
|
353
|
+
i += 1
|
|
354
|
+
|
|
355
|
+
# Continuation lines
|
|
356
|
+
while i < end and lines[i].strip() != '':
|
|
357
|
+
ln = lines[i]
|
|
358
|
+
if UL_RE.match(ln) or OL_RE.match(ln):
|
|
359
|
+
break
|
|
360
|
+
if HEADING_RE.match(ln) or FENCE_RE.match(ln.strip()):
|
|
361
|
+
break
|
|
362
|
+
raw_lines.append(ln)
|
|
363
|
+
content_lines.append(ln)
|
|
364
|
+
i += 1
|
|
365
|
+
|
|
366
|
+
items.append({
|
|
367
|
+
'raw_lines': raw_lines,
|
|
368
|
+
'content': ' '.join(content_lines),
|
|
369
|
+
'todo': todo,
|
|
370
|
+
'done': done,
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
return items
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
377
|
+
# Renderer
|
|
378
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
379
|
+
|
|
380
|
+
def render_inline(tokens):
|
|
381
|
+
"""Render parsed inline tokens to a string with ANSI codes."""
|
|
382
|
+
parts = []
|
|
383
|
+
for typ, text, url in tokens:
|
|
384
|
+
if typ == 'text':
|
|
385
|
+
parts.append(text)
|
|
386
|
+
elif typ in ('bold', 'italic', 'bold_italic', 'code', 'strikethrough'):
|
|
387
|
+
parts.append(style(text, typ))
|
|
388
|
+
elif typ == 'link':
|
|
389
|
+
parts.append(style(text, 'link'))
|
|
390
|
+
if url and url != text:
|
|
391
|
+
parts.append(' ' + style(f'({url})', 'dim'))
|
|
392
|
+
elif typ == 'image':
|
|
393
|
+
alt = text or url
|
|
394
|
+
parts.append(style(f'[IMG: {alt}]', 'image'))
|
|
395
|
+
return ''.join(parts)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def get_term_width():
|
|
399
|
+
"""Detect terminal width, defaulting to 80."""
|
|
400
|
+
try:
|
|
401
|
+
return shutil.get_terminal_size().columns
|
|
402
|
+
except Exception:
|
|
403
|
+
return 80
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def visible_len(s):
|
|
407
|
+
"""Display width excluding ANSI escape sequences. CJK chars count as 2."""
|
|
408
|
+
w = 0
|
|
409
|
+
in_esc = False
|
|
410
|
+
i = 0
|
|
411
|
+
while i < len(s):
|
|
412
|
+
if in_esc:
|
|
413
|
+
if s[i] == 'm':
|
|
414
|
+
in_esc = False
|
|
415
|
+
i += 1
|
|
416
|
+
continue
|
|
417
|
+
if s[i] == '\033' and i + 1 < len(s) and s[i + 1] == '[':
|
|
418
|
+
in_esc = True
|
|
419
|
+
i += 2
|
|
420
|
+
continue
|
|
421
|
+
cp = ord(s[i])
|
|
422
|
+
w += 2 if _is_wide(cp) else 1
|
|
423
|
+
i += 1
|
|
424
|
+
return w
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _is_wide(cp):
|
|
428
|
+
"""Check if a Unicode codepoint is East Asian Wide or Fullwidth."""
|
|
429
|
+
return (
|
|
430
|
+
(0x1100 <= cp <= 0x115F) or # Hangul Jamo
|
|
431
|
+
(0x2329 <= cp <= 0x232A) or # Angle brackets
|
|
432
|
+
(0x2E80 <= cp <= 0xA4CF) or # CJK Radicals .. Yi
|
|
433
|
+
(0xA960 <= cp <= 0xA97C) or # Hangul Extended
|
|
434
|
+
(0xAC00 <= cp <= 0xD7A3) or # Hangul Syllables
|
|
435
|
+
(0xF900 <= cp <= 0xFAFF) or # CJK Compat
|
|
436
|
+
(0xFE10 <= cp <= 0xFE19) or # Vertical forms
|
|
437
|
+
(0xFE30 <= cp <= 0xFE6F) or # CJK Compat Forms
|
|
438
|
+
(0xFF01 <= cp <= 0xFF60) or # Fullwidth Forms
|
|
439
|
+
(0xFFE0 <= cp <= 0xFFE6) or # Fullwidth Signs
|
|
440
|
+
(0x1F300 <= cp <= 0x1F64F) or # Misc Symbols / Emoji
|
|
441
|
+
(0x1F900 <= cp <= 0x1F9FF) or # Supplemental Symbols
|
|
442
|
+
(0x20000 <= cp <= 0x3FFFF) # CJK Extension B+
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def wrap_text(text, width, indent=0, first_indent=0):
|
|
447
|
+
"""Word-wrap text to `width`, returning lines. Handles ANSI codes."""
|
|
448
|
+
avail = width - first_indent
|
|
449
|
+
if avail <= 0:
|
|
450
|
+
avail = width
|
|
451
|
+
|
|
452
|
+
lines = []
|
|
453
|
+
cur = ''
|
|
454
|
+
cur_vis = 0
|
|
455
|
+
target = avail
|
|
456
|
+
|
|
457
|
+
# Split into words but keep ANSI sequences attached
|
|
458
|
+
words = []
|
|
459
|
+
buf = ''
|
|
460
|
+
in_esc = False
|
|
461
|
+
for ch in text:
|
|
462
|
+
if in_esc:
|
|
463
|
+
buf += ch
|
|
464
|
+
if ch == 'm':
|
|
465
|
+
in_esc = False
|
|
466
|
+
continue
|
|
467
|
+
if ch == '\033':
|
|
468
|
+
in_esc = True
|
|
469
|
+
buf += ch
|
|
470
|
+
continue
|
|
471
|
+
if ch == ' ':
|
|
472
|
+
if buf:
|
|
473
|
+
words.append(buf)
|
|
474
|
+
buf = ''
|
|
475
|
+
else:
|
|
476
|
+
buf += ch
|
|
477
|
+
if buf:
|
|
478
|
+
words.append(buf)
|
|
479
|
+
|
|
480
|
+
for word in words:
|
|
481
|
+
w_vis = visible_len(word)
|
|
482
|
+
if cur == '':
|
|
483
|
+
if w_vis > target:
|
|
484
|
+
# Single word too long, let it overflow
|
|
485
|
+
lines.append(' ' * first_indent + word)
|
|
486
|
+
target = avail
|
|
487
|
+
else:
|
|
488
|
+
cur = word
|
|
489
|
+
cur_vis = w_vis
|
|
490
|
+
target = avail
|
|
491
|
+
elif cur_vis + 1 + w_vis > target:
|
|
492
|
+
lines.append(' ' * first_indent + cur)
|
|
493
|
+
cur = word
|
|
494
|
+
cur_vis = w_vis
|
|
495
|
+
first_indent = indent
|
|
496
|
+
target = width - indent
|
|
497
|
+
else:
|
|
498
|
+
cur += ' ' + word
|
|
499
|
+
cur_vis += 1 + w_vis
|
|
500
|
+
|
|
501
|
+
if cur:
|
|
502
|
+
lines.append(' ' * first_indent + cur)
|
|
503
|
+
|
|
504
|
+
return lines or ['']
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _balance_ansi_lines(lines):
|
|
508
|
+
"""Close and reopen ANSI codes across line boundaries."""
|
|
509
|
+
if len(lines) <= 1:
|
|
510
|
+
return lines
|
|
511
|
+
|
|
512
|
+
def _active_codes_at_end(s):
|
|
513
|
+
"""Return the ANSI prefix string that represents open codes at end of s."""
|
|
514
|
+
in_esc = False
|
|
515
|
+
current = '' # last full escape sequence seen
|
|
516
|
+
depth = 0
|
|
517
|
+
for i, ch in enumerate(s):
|
|
518
|
+
if in_esc:
|
|
519
|
+
if ch == 'm':
|
|
520
|
+
in_esc = False
|
|
521
|
+
seq = s[esc_start:i+1]
|
|
522
|
+
if seq == '\033[0m':
|
|
523
|
+
current = ''
|
|
524
|
+
else:
|
|
525
|
+
current = seq
|
|
526
|
+
continue
|
|
527
|
+
if ch == '\033' and i+1 < len(s) and s[i+1] == '[':
|
|
528
|
+
esc_start = i
|
|
529
|
+
in_esc = True
|
|
530
|
+
return current
|
|
531
|
+
|
|
532
|
+
result = [lines[0]]
|
|
533
|
+
for i in range(1, len(lines)):
|
|
534
|
+
prev = result[-1]
|
|
535
|
+
active = _active_codes_at_end(prev)
|
|
536
|
+
curr = lines[i]
|
|
537
|
+
# Close previous line's open codes
|
|
538
|
+
if active:
|
|
539
|
+
result[-1] = prev + '\033[0m'
|
|
540
|
+
# Reopen codes on continuation line
|
|
541
|
+
if active and not curr.startswith('\033'):
|
|
542
|
+
curr = active + curr
|
|
543
|
+
result.append(curr)
|
|
544
|
+
return result
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def render_block(block, width):
|
|
548
|
+
"""Render a single block to a list of lines (with ANSI)."""
|
|
549
|
+
typ = block['type']
|
|
550
|
+
|
|
551
|
+
if typ == 'heading':
|
|
552
|
+
level = block['level']
|
|
553
|
+
content = block['content']
|
|
554
|
+
tokens = parse_inline(content)
|
|
555
|
+
text = render_inline(tokens)
|
|
556
|
+
name = f'h{level}'
|
|
557
|
+
marker = '■' if level == 1 else '▸'
|
|
558
|
+
lines = [style(marker + ' ' + text, name)]
|
|
559
|
+
return lines
|
|
560
|
+
|
|
561
|
+
elif typ == 'paragraph':
|
|
562
|
+
tokens = parse_inline(block['content'])
|
|
563
|
+
text = render_inline(tokens)
|
|
564
|
+
return wrap_text(text, width)
|
|
565
|
+
|
|
566
|
+
elif typ == 'code_block':
|
|
567
|
+
code_lines = block['lines']
|
|
568
|
+
lang = block['language']
|
|
569
|
+
max_len = max((visible_len(l.expandtabs(4)) for l in code_lines), default=0)
|
|
570
|
+
content_w = min(max_len + 4, width - 2)
|
|
571
|
+
content_w = max(content_w, 20)
|
|
572
|
+
|
|
573
|
+
result = []
|
|
574
|
+
if lang:
|
|
575
|
+
label = f' {lang} '
|
|
576
|
+
result.append(style('┌' + label + '─' * (content_w - 2 - len(label)), 'table_border'))
|
|
577
|
+
else:
|
|
578
|
+
result.append(style('┌' + '─' * (content_w - 2), 'table_border'))
|
|
579
|
+
|
|
580
|
+
for line in code_lines:
|
|
581
|
+
display = line.expandtabs(4)
|
|
582
|
+
vl = visible_len(display)
|
|
583
|
+
if vl > content_w - 4:
|
|
584
|
+
display = display[:content_w - 5] + '…'
|
|
585
|
+
vl = visible_len(display)
|
|
586
|
+
pad = content_w - 4 - vl
|
|
587
|
+
result.append(
|
|
588
|
+
style('│', 'table_border') +
|
|
589
|
+
' ' + display + ' ' * max(pad, 0) + ' '
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
result.append(style('└' + '─' * (content_w - 2), 'table_border'))
|
|
593
|
+
return result
|
|
594
|
+
|
|
595
|
+
elif typ == 'blockquote':
|
|
596
|
+
avail = width - 4
|
|
597
|
+
if avail < 20:
|
|
598
|
+
avail = 20
|
|
599
|
+
result = []
|
|
600
|
+
for line in block['lines']:
|
|
601
|
+
if line == '':
|
|
602
|
+
result.append(style('▎', 'bq_bar'))
|
|
603
|
+
continue
|
|
604
|
+
tokens = parse_inline(line)
|
|
605
|
+
text = render_inline(tokens)
|
|
606
|
+
wrapped = _balance_ansi_lines(wrap_text(text, width, indent=2, first_indent=2))
|
|
607
|
+
for wl in wrapped:
|
|
608
|
+
# Prefix with colored bar
|
|
609
|
+
result.append(style('▎', 'bq_bar') + ' ' + style(wl.strip(), 'bq_text'))
|
|
610
|
+
return result
|
|
611
|
+
|
|
612
|
+
elif typ == 'list':
|
|
613
|
+
result = []
|
|
614
|
+
indent = ' '
|
|
615
|
+
bullet_indent = 2
|
|
616
|
+
avail = width - 4
|
|
617
|
+
if avail < 20:
|
|
618
|
+
avail = 20
|
|
619
|
+
|
|
620
|
+
for idx, item in enumerate(block['items']):
|
|
621
|
+
tokens = parse_inline(item['content'])
|
|
622
|
+
text = render_inline(tokens)
|
|
623
|
+
|
|
624
|
+
if item.get('todo'):
|
|
625
|
+
if item.get('done'):
|
|
626
|
+
bullet = style('●', 'h4') # green filled circle
|
|
627
|
+
else:
|
|
628
|
+
bullet = style('○', 'dim') # gray empty circle
|
|
629
|
+
elif block['ordered']:
|
|
630
|
+
num = block['start'] + idx
|
|
631
|
+
bullet = style(f'{num}.', 'list_num')
|
|
632
|
+
else:
|
|
633
|
+
bullet = style('•', 'bullet')
|
|
634
|
+
|
|
635
|
+
wrapped = wrap_text(text, width, indent=4, first_indent=2)
|
|
636
|
+
if wrapped:
|
|
637
|
+
result.append(indent + bullet + ' ' + wrapped[0][bullet_indent:].strip())
|
|
638
|
+
for wl in wrapped[1:]:
|
|
639
|
+
result.append(indent + ' ' + wl.strip())
|
|
640
|
+
else:
|
|
641
|
+
result.append(indent + bullet)
|
|
642
|
+
return result
|
|
643
|
+
|
|
644
|
+
elif typ == 'table':
|
|
645
|
+
header = block['header']
|
|
646
|
+
rows = block['rows']
|
|
647
|
+
aligns = block.get('aligns', [])
|
|
648
|
+
|
|
649
|
+
# Render all cells with inline formatting
|
|
650
|
+
def render_cell(cell_text):
|
|
651
|
+
tokens = parse_inline(cell_text)
|
|
652
|
+
return render_inline(tokens)
|
|
653
|
+
|
|
654
|
+
hdr_rendered = [[render_cell(c) for c in h] for h in header] if header else []
|
|
655
|
+
data_rendered = [[render_cell(c) for c in row] for row in rows]
|
|
656
|
+
|
|
657
|
+
ncols = max(
|
|
658
|
+
(len(r) for r in (hdr_rendered + data_rendered)),
|
|
659
|
+
default=0
|
|
660
|
+
)
|
|
661
|
+
if ncols == 0:
|
|
662
|
+
return ['']
|
|
663
|
+
|
|
664
|
+
# Pad rows to ncols
|
|
665
|
+
for r in hdr_rendered:
|
|
666
|
+
while len(r) < ncols:
|
|
667
|
+
r.append('')
|
|
668
|
+
for r in data_rendered:
|
|
669
|
+
while len(r) < ncols:
|
|
670
|
+
r.append('')
|
|
671
|
+
|
|
672
|
+
# Min column width = 4
|
|
673
|
+
min_w = 4
|
|
674
|
+
# Border overhead: 1 (leading │) + ncols*1 (trailing │ + inner │)
|
|
675
|
+
overhead = 1 + ncols + 1
|
|
676
|
+
avail_total = width - overhead
|
|
677
|
+
if avail_total < ncols * min_w:
|
|
678
|
+
avail_total = ncols * min_w
|
|
679
|
+
|
|
680
|
+
# Step 1: compute desired width per column (max visible line width)
|
|
681
|
+
def max_line_width(text, limit=None):
|
|
682
|
+
"""Max visible width of any line in text after wrapping at limit."""
|
|
683
|
+
if limit:
|
|
684
|
+
wrapped = wrap_text(text, limit)
|
|
685
|
+
return max((visible_len(l) for l in wrapped), default=0)
|
|
686
|
+
return visible_len(text)
|
|
687
|
+
|
|
688
|
+
desired = [min_w] * ncols
|
|
689
|
+
for row in hdr_rendered + data_rendered:
|
|
690
|
+
for j, cell in enumerate(row):
|
|
691
|
+
w = max_line_width(cell)
|
|
692
|
+
if w > desired[j]:
|
|
693
|
+
desired[j] = w
|
|
694
|
+
|
|
695
|
+
# Step 2: allocate column widths — compact by default, shrink only if needed
|
|
696
|
+
col_widths = list(desired)
|
|
697
|
+
total_desired = sum(col_widths)
|
|
698
|
+
if total_desired > avail_total:
|
|
699
|
+
# Scale down proportionally, but give wider columns more
|
|
700
|
+
remaining = avail_total
|
|
701
|
+
# First pass: cap each column at its desired width, assign min
|
|
702
|
+
for j in range(ncols):
|
|
703
|
+
col_widths[j] = min_w
|
|
704
|
+
remaining -= min_w
|
|
705
|
+
# Second pass: distribute remaining by desired ratio
|
|
706
|
+
if remaining > 0:
|
|
707
|
+
excess_desired = [max(0, desired[j] - min_w) for j in range(ncols)]
|
|
708
|
+
total_excess = sum(excess_desired)
|
|
709
|
+
if total_excess > 0:
|
|
710
|
+
for j in range(ncols):
|
|
711
|
+
if excess_desired[j] > 0:
|
|
712
|
+
extra = int(remaining * excess_desired[j] / total_excess)
|
|
713
|
+
col_widths[j] = min_w + extra
|
|
714
|
+
# Distribute any rounding remainder
|
|
715
|
+
leftover = avail_total - sum(col_widths)
|
|
716
|
+
for j in range(ncols):
|
|
717
|
+
if leftover <= 0:
|
|
718
|
+
break
|
|
719
|
+
if col_widths[j] < desired[j]:
|
|
720
|
+
col_widths[j] += 1
|
|
721
|
+
leftover -= 1
|
|
722
|
+
|
|
723
|
+
# Step 3: wrap each cell to column width
|
|
724
|
+
def wrap_cell(text, cw):
|
|
725
|
+
"""Wrap text to column width, return list of lines with balanced ANSI."""
|
|
726
|
+
if cw <= 0:
|
|
727
|
+
return [text]
|
|
728
|
+
raw_lines = wrap_text(text, cw)
|
|
729
|
+
return _balance_ansi_lines(raw_lines)
|
|
730
|
+
|
|
731
|
+
def pad_line(text, cw, align):
|
|
732
|
+
"""Pad a single line to exact column width."""
|
|
733
|
+
vl = visible_len(text)
|
|
734
|
+
if vl >= cw:
|
|
735
|
+
return text
|
|
736
|
+
pad = cw - vl
|
|
737
|
+
if align == 'center':
|
|
738
|
+
return ' ' * (pad // 2) + text + ' ' * (pad - pad // 2)
|
|
739
|
+
elif align == 'right':
|
|
740
|
+
return ' ' * pad + text
|
|
741
|
+
else:
|
|
742
|
+
return text + ' ' * pad
|
|
743
|
+
|
|
744
|
+
# Wrap all cells
|
|
745
|
+
hdr_wrapped = []
|
|
746
|
+
for row in hdr_rendered:
|
|
747
|
+
hdr_wrapped.append([wrap_cell(c, col_widths[j]) for j, c in enumerate(row)])
|
|
748
|
+
data_wrapped = []
|
|
749
|
+
for row in data_rendered:
|
|
750
|
+
data_wrapped.append([wrap_cell(c, col_widths[j]) for j, c in enumerate(row)])
|
|
751
|
+
|
|
752
|
+
def hline(left, mid, right):
|
|
753
|
+
parts = [left]
|
|
754
|
+
for j in range(ncols):
|
|
755
|
+
parts.append('─' * col_widths[j])
|
|
756
|
+
parts.append(mid if j < ncols - 1 else right)
|
|
757
|
+
return style(''.join(parts), 'table_border')
|
|
758
|
+
|
|
759
|
+
def render_wrapped_rows(wrapped_rows, row_style=None):
|
|
760
|
+
lines = []
|
|
761
|
+
for row_cells in wrapped_rows:
|
|
762
|
+
max_lines = max((len(l) for l in row_cells), default=1)
|
|
763
|
+
for line_idx in range(max_lines):
|
|
764
|
+
parts = [style('│', 'table_border')]
|
|
765
|
+
for j in range(ncols):
|
|
766
|
+
cell_lines = row_cells[j]
|
|
767
|
+
text = cell_lines[line_idx] if line_idx < len(cell_lines) else ''
|
|
768
|
+
align = aligns[j] if j < len(aligns) else 'left'
|
|
769
|
+
cell = pad_line(text, col_widths[j], align)
|
|
770
|
+
if row_style:
|
|
771
|
+
cell = style(cell, row_style)
|
|
772
|
+
parts.append(cell)
|
|
773
|
+
parts.append(style('│', 'table_border'))
|
|
774
|
+
lines.append(''.join(parts))
|
|
775
|
+
return lines
|
|
776
|
+
|
|
777
|
+
result = [hline('┌', '┬', '┐')]
|
|
778
|
+
|
|
779
|
+
if header:
|
|
780
|
+
for h in hdr_wrapped:
|
|
781
|
+
result.extend(render_wrapped_rows([h], 'table_head'))
|
|
782
|
+
elif data_wrapped:
|
|
783
|
+
result.extend(render_wrapped_rows([data_wrapped[0]], 'table_head'))
|
|
784
|
+
data_wrapped = data_wrapped[1:]
|
|
785
|
+
|
|
786
|
+
if data_wrapped or (header and not data_wrapped):
|
|
787
|
+
result.append(hline('├', '┼', '┤'))
|
|
788
|
+
|
|
789
|
+
result.extend(render_wrapped_rows(data_wrapped))
|
|
790
|
+
|
|
791
|
+
result.append(hline('└', '┴', '┘'))
|
|
792
|
+
return result
|
|
793
|
+
|
|
794
|
+
elif typ == 'hr':
|
|
795
|
+
return [style('─' * width, 'hr')]
|
|
796
|
+
|
|
797
|
+
return ['']
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def render(blocks, width=None):
|
|
801
|
+
"""Render all blocks to a string with proper spacing."""
|
|
802
|
+
if width is None:
|
|
803
|
+
width = get_term_width()
|
|
804
|
+
|
|
805
|
+
lines = []
|
|
806
|
+
prev_type = None
|
|
807
|
+
|
|
808
|
+
for block in blocks:
|
|
809
|
+
typ = block['type']
|
|
810
|
+
|
|
811
|
+
# Add blank line between different block types for readability
|
|
812
|
+
if prev_type is not None:
|
|
813
|
+
same_type = (prev_type == typ)
|
|
814
|
+
if not same_type or typ in ('paragraph', 'heading'):
|
|
815
|
+
lines.append('')
|
|
816
|
+
|
|
817
|
+
rendered = render_block(block, width)
|
|
818
|
+
lines.extend(rendered)
|
|
819
|
+
prev_type = typ
|
|
820
|
+
|
|
821
|
+
if lines:
|
|
822
|
+
lines.append('')
|
|
823
|
+
return '\n'.join(lines)
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
827
|
+
# CLI
|
|
828
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
829
|
+
|
|
830
|
+
def main():
|
|
831
|
+
if len(sys.argv) > 1:
|
|
832
|
+
path = sys.argv[1]
|
|
833
|
+
try:
|
|
834
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
835
|
+
text = f.read()
|
|
836
|
+
except FileNotFoundError:
|
|
837
|
+
print(f'pmd: {path}: file not found', file=sys.stderr)
|
|
838
|
+
sys.exit(1)
|
|
839
|
+
except Exception as e:
|
|
840
|
+
print(f'pmd: {path}: {e}', file=sys.stderr)
|
|
841
|
+
sys.exit(1)
|
|
842
|
+
elif not sys.stdin.isatty():
|
|
843
|
+
text = sys.stdin.read()
|
|
844
|
+
else:
|
|
845
|
+
print('Usage: pmd <file.md>', file=sys.stderr)
|
|
846
|
+
sys.exit(1)
|
|
847
|
+
|
|
848
|
+
blocks = parse_blocks(text)
|
|
849
|
+
output = render(blocks)
|
|
850
|
+
sys.stdout.write(output)
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
if __name__ == '__main__':
|
|
854
|
+
main()
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pmd-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Print Markdown — terminal Markdown renderer with ANSI formatting. Zero dependencies.
|
|
5
|
+
Author: mdcli contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: markdown,terminal,cli,renderer,ansi
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Terminals
|
|
19
|
+
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# pmd — Print Markdown
|
|
24
|
+
|
|
25
|
+
> Terminal Markdown renderer. Zero dependencies. One command.
|
|
26
|
+
|
|
27
|
+
**pmd** renders Markdown files directly in your terminal with ANSI colors and Unicode box-drawing characters. No browser, no GUI, no npm install. Just Python 3.9+ and `pip install pmd-cli`.
|
|
28
|
+
|
|
29
|
+
<a href="https://pypi.org/project/pmd-cli/"><img src="https://img.shields.io/pypi/v/pmd" alt="PyPI"></a>
|
|
30
|
+
<a href="https://pypi.org/project/pmd-cli/"><img src="https://img.shields.io/pypi/pyversions/pmd" alt="Python 3.9+"></a>
|
|
31
|
+
<a href="https://pypi.org/project/pmd-cli/"><img src="https://img.shields.io/pypi/l/pmd" alt="License MIT"></a>
|
|
32
|
+
|
|
33
|
+
[*中文版*](README_CN.md)
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install pmd-cli # or: pipx install pmd-cli
|
|
39
|
+
pmd README.md
|
|
40
|
+
cat doc.md | pmd # stdin works too
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Features
|
|
44
|
+
|
|
45
|
+
### Headings — 6 levels with colors
|
|
46
|
+
|
|
47
|
+
| Level | Style | Marker |
|
|
48
|
+
|-------|-------|--------|
|
|
49
|
+
| H1 | White on magenta | ■ |
|
|
50
|
+
| H2 | White on blue | ▸ |
|
|
51
|
+
| H3 | Black on cyan | ▸ |
|
|
52
|
+
| H4–H6 | Green / Yellow / Gray | ▸ |
|
|
53
|
+
|
|
54
|
+
No underlines, no `#` prefixes — just color and shape.
|
|
55
|
+
|
|
56
|
+
### Inline formatting
|
|
57
|
+
|
|
58
|
+
- **Bold** `**text**`
|
|
59
|
+
- *Italic* `*text*`
|
|
60
|
+
- ***Bold-italic*** `***text***`
|
|
61
|
+
- ~~Strikethrough~~ `~~text~~`
|
|
62
|
+
- `Code` — **bold green** for visibility
|
|
63
|
+
- [Links](https://example.com) — blue underlined
|
|
64
|
+
|
|
65
|
+
### Code blocks — bordered, language-tagged
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
┌ python ──────────────────
|
|
69
|
+
│ def hello():
|
|
70
|
+
│ print("world")
|
|
71
|
+
└──────────────────────────
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Clean box borders, language label in the top frame. No syntax highlighting — just readable code.
|
|
75
|
+
|
|
76
|
+
### Tables — autofit, cell wrapping, inline formatting
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
┌──────────┬───────┬─────────────────┐
|
|
80
|
+
│ Name │ Age │ City │
|
|
81
|
+
├──────────┼───────┼─────────────────┤
|
|
82
|
+
│ Alice │ 30 │ Beijing │
|
|
83
|
+
│ Bob │ 25 │ Shanghai │
|
|
84
|
+
└──────────┴───────┴─────────────────┘
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
- Unicode box-drawing glyphs
|
|
88
|
+
- Columns autofit to content — **compact by default**
|
|
89
|
+
- Long cells wrap within their column
|
|
90
|
+
- Inline formatting (`` `code` ``, `**bold**`) preserved inside cells
|
|
91
|
+
|
|
92
|
+
### Task lists
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
● Completed task
|
|
96
|
+
○ Pending task
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
GFM-style `- [x]` / `- [ ]` rendered as green filled / gray empty circles. Mix with regular list items freely.
|
|
100
|
+
|
|
101
|
+
### Blockquotes
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
▎ Cyan bar + dimmed text
|
|
105
|
+
▎ Clean and compact
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### CJK / Emoji support
|
|
109
|
+
|
|
110
|
+
Chinese, Japanese, Korean characters measured at correct display width (2 columns). Table alignment and text wrapping work properly for mixed-script documents.
|
|
111
|
+
|
|
112
|
+
## Why pmd?
|
|
113
|
+
|
|
114
|
+
| Tool | Language | Dependencies |
|
|
115
|
+
|------|----------|-------------|
|
|
116
|
+
| **pmd** | Python 3 | **Zero** |
|
|
117
|
+
| `glow` | Go | Requires Go toolchain or prebuilt binary |
|
|
118
|
+
| `rich-cli` | Python | `rich` + 10+ transitive deps |
|
|
119
|
+
| `mdcat` | Rust | Requires Rust or prebuilt binary |
|
|
120
|
+
| `mdr` | Ruby | Requires Ruby + `gem install` |
|
|
121
|
+
|
|
122
|
+
pmd is the lightest option when you need markdown rendering on a server, container, CI runner, or air-gapped machine where installing compilers or heavy packages isn't practical.
|
|
123
|
+
|
|
124
|
+
## Alternatives vs. built-in `less` / `cat`
|
|
125
|
+
|
|
126
|
+
These show raw markdown source:
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
# Heading ← looks like a comment
|
|
130
|
+
**bold** *italic* ← noise characters everywhere
|
|
131
|
+
| table | columns | ← misaligned without rendering
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
pmd shows the **rendered** document — the way it was meant to be read.
|
|
135
|
+
|
|
136
|
+
## Install
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
pip install pmd-cli # system-wide
|
|
140
|
+
pip install --user pmd-cli # user only
|
|
141
|
+
pipx install pmd-cli # isolated CLI (recommended)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Python 3.9 or later. No other dependencies.
|
|
145
|
+
|
|
146
|
+
## Usage
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
# File
|
|
150
|
+
pmd README.md
|
|
151
|
+
|
|
152
|
+
# Stdin
|
|
153
|
+
curl -s https://example.com/doc.md | pmd
|
|
154
|
+
|
|
155
|
+
# From clipboard (macOS)
|
|
156
|
+
pbpaste | pmd
|
|
157
|
+
|
|
158
|
+
# From clipboard (Linux)
|
|
159
|
+
xclip -o | pmd
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Project structure
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
src/pmd/
|
|
166
|
+
__init__.py # version
|
|
167
|
+
__main__.py # parser + renderer (~850 lines)
|
|
168
|
+
pyproject.toml # pip metadata
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Single-file core — easy to vendor, fork, or embed.
|
|
172
|
+
|
|
173
|
+
## License
|
|
174
|
+
|
|
175
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
pmd/__init__.py,sha256=iZiAvIsGZ5Cn4Jd4pI5T-nkYe65AZvkvCNhV61l8BVo,100
|
|
2
|
+
pmd/__main__.py,sha256=yojaY1xvlFju5nAkitO0MIyeLuHL-nRrnhhjbveiIz4,28966
|
|
3
|
+
pmd_cli-1.0.0.dist-info/METADATA,sha256=AxQbtmwD3o_utU8glB6PLELN8VBHQ7KPQoYWBBi-8i8,5147
|
|
4
|
+
pmd_cli-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
5
|
+
pmd_cli-1.0.0.dist-info/entry_points.txt,sha256=SP7nt0vSx8oQBhnszAPTqm-i7dypb9afXv-YOCay8Qo,42
|
|
6
|
+
pmd_cli-1.0.0.dist-info/top_level.txt,sha256=hNZwLVj0bVL9MJURPFwFs29rU_qp4yN_-srZ2na5_tc,4
|
|
7
|
+
pmd_cli-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pmd
|