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 ADDED
@@ -0,0 +1,3 @@
1
+ """pmd - Print Markdown. Terminal Markdown renderer with ANSI formatting."""
2
+
3
+ __version__ = "1.0.0"
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pmd = pmd.__main__:main
@@ -0,0 +1 @@
1
+ pmd