feat: add md_to_pdf.py Markdown-to-PDF conversion utility
This commit is contained in:
parent
2eac94f719
commit
0b5372a976
|
|
@ -0,0 +1,192 @@
|
|||
#!/usr/bin/env python3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import textwrap
|
||||
import re
|
||||
|
||||
try:
|
||||
from reportlab.lib.pagesizes import letter
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.lib import colors
|
||||
except Exception as e:
|
||||
print('Missing reportlab:', e)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def wrap_text(text, max_chars):
|
||||
return textwrap.wrap(text, width=max_chars)
|
||||
|
||||
|
||||
def draw_wrapped(c, text, x, y, font_name, font_size, max_width, indent=0):
|
||||
# Estimate chars per line roughly
|
||||
approx_char_width = font_size * 0.55
|
||||
max_chars = max(20, int((max_width - indent) / approx_char_width))
|
||||
wrapped = wrap_text(text, max_chars)
|
||||
line_height = font_size * 1.25
|
||||
for line in wrapped:
|
||||
c.drawString(x + indent, y, line)
|
||||
y -= line_height
|
||||
return y
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import textwrap
|
||||
|
||||
try:
|
||||
from reportlab.lib.pagesizes import letter
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.lib import colors
|
||||
except Exception as e:
|
||||
print('Missing reportlab:', e)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def wrap_text(text, max_chars):
|
||||
return textwrap.wrap(text, width=max_chars)
|
||||
|
||||
|
||||
def draw_wrapped(c, text, x, y, font_name, font_size, max_width, indent=0):
|
||||
# Estimate chars per line roughly
|
||||
approx_char_width = font_size * 0.55
|
||||
max_chars = max(20, int((max_width - indent) / approx_char_width))
|
||||
wrapped = wrap_text(text, max_chars)
|
||||
line_height = font_size * 1.25
|
||||
for line in wrapped:
|
||||
c.drawString(x + indent, y, line)
|
||||
y -= line_height
|
||||
return y
|
||||
|
||||
|
||||
if len(sys.argv) < 3:
|
||||
print('Usage: md_to_pdf.py input.md output.pdf')
|
||||
sys.exit(1)
|
||||
|
||||
in_path = Path(sys.argv[1])
|
||||
out_path = Path(sys.argv[2])
|
||||
|
||||
if not in_path.exists():
|
||||
print('Input file not found:', in_path)
|
||||
sys.exit(1)
|
||||
|
||||
text = in_path.read_text(encoding='utf-8')
|
||||
lines = text.splitlines()
|
||||
|
||||
c = canvas.Canvas(str(out_path), pagesize=letter)
|
||||
width, height = letter
|
||||
left = inch * 0.75
|
||||
right = inch * 0.75
|
||||
top = height - inch * 0.75
|
||||
bottom = inch * 0.75
|
||||
max_width = width - left - right
|
||||
|
||||
# default fonts
|
||||
REGULAR = 'Helvetica'
|
||||
BOLD = 'Helvetica-Bold'
|
||||
MONO = 'Courier'
|
||||
|
||||
y = top
|
||||
|
||||
in_code = False
|
||||
code_font_size = 8
|
||||
para_font_size = 10
|
||||
|
||||
for raw in lines:
|
||||
line = raw.rstrip('\n')
|
||||
if line.strip() == '':
|
||||
y -= para_font_size * 0.6
|
||||
if y < bottom:
|
||||
c.showPage()
|
||||
y = top
|
||||
continue
|
||||
|
||||
# Code fence toggle
|
||||
if line.strip().startswith('```'):
|
||||
in_code = not in_code
|
||||
if in_code:
|
||||
y -= 6
|
||||
else:
|
||||
y -= 6
|
||||
if y < bottom:
|
||||
c.showPage()
|
||||
y = top
|
||||
continue
|
||||
|
||||
if in_code:
|
||||
c.setFont(MONO, code_font_size)
|
||||
# draw code line with small left indent
|
||||
y = draw_wrapped(c, line, left, y, MONO, code_font_size, max_width, indent=10)
|
||||
c.setFont(REGULAR, para_font_size)
|
||||
if y < bottom:
|
||||
c.showPage()
|
||||
y = top
|
||||
continue
|
||||
|
||||
# Headings
|
||||
if line.lstrip().startswith('#'):
|
||||
hashes, _, rest = line.partition(' ')
|
||||
level = hashes.count('#')
|
||||
text = rest.strip()
|
||||
# add extra space BEFORE top-level headings so they don't butt against previous paragraph
|
||||
if level == 1:
|
||||
y -= para_font_size * 1.8
|
||||
elif level == 2:
|
||||
y -= para_font_size * 0.8
|
||||
else:
|
||||
y -= para_font_size * 0.6
|
||||
|
||||
if level == 1:
|
||||
font_size = 18
|
||||
font = BOLD
|
||||
elif level == 2:
|
||||
font_size = 14
|
||||
font = BOLD
|
||||
else:
|
||||
font_size = 12
|
||||
font = BOLD
|
||||
c.setFont(font, font_size)
|
||||
y = draw_wrapped(c, text, left, y, font, font_size, max_width)
|
||||
# modest spacing after headings
|
||||
y -= para_font_size * 0.6
|
||||
c.setFont(REGULAR, para_font_size)
|
||||
if y < bottom:
|
||||
c.showPage()
|
||||
y = top
|
||||
continue
|
||||
|
||||
# Horizontal rule
|
||||
if line.strip() in ('---', '***', '___'):
|
||||
c.setStrokeColor(colors.black)
|
||||
c.setLineWidth(1)
|
||||
y -= 6
|
||||
c.line(left, y, left + max_width, y)
|
||||
y -= 12
|
||||
if y < bottom:
|
||||
c.showPage()
|
||||
y = top
|
||||
continue
|
||||
|
||||
# Lists
|
||||
stripped = line.lstrip()
|
||||
if stripped.startswith(('- ', '* ', '+ ')) or stripped[:2].isdigit() and stripped[2:].startswith('. '):
|
||||
bullet = '•'
|
||||
content = stripped[2:].strip() if not stripped[0].isdigit() else stripped.split('.', 1)[1].strip()
|
||||
c.setFont(REGULAR, para_font_size)
|
||||
# draw bullet and wrapped content with indent
|
||||
c.drawString(left, y, bullet)
|
||||
y = draw_wrapped(c, content, left + 12, y, REGULAR, para_font_size, max_width, indent=0)
|
||||
if y < bottom:
|
||||
c.showPage()
|
||||
y = top
|
||||
continue
|
||||
|
||||
# Paragraph
|
||||
c.setFont(REGULAR, para_font_size)
|
||||
y = draw_wrapped(c, line, left, y, REGULAR, para_font_size, max_width)
|
||||
if y < bottom:
|
||||
c.showPage()
|
||||
y = top
|
||||
|
||||
c.save()
|
||||
print('Wrote PDF:', out_path)
|
||||
Loading…
Reference in New Issue