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