[Python] 纯文本查看 复制代码
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
md_editor PDF generator.
APP_SCRIPT_VERSION: 3
(md_editor only re-seeds this file when the embedded version marker differs,
so local edits survive app updates.)
Converts a Markdown document to PDF with reportlab. The app hands over:
python pdf_gen.py <input.md> <output.pdf> [--base <dir>] [--images <dir>] [--no-images]
--base directory used to resolve relative image paths (usually the
folder of the source document)
--images directory with copies of every embedded image (the app writes
them there with their original extensions, including decoded
data: URLs and downloaded remote images)
--no-images keep image links as plain text instead of embedding them
Images are converted to JPEG via Pillow before embedding (PDF only supports
.jpg/.png natively). H1/H2 headings become PDF outline entries.
Requirements: pip install reportlab pillow
The script is standalone by design: edit it freely (it lives next to the
app data, and the app never overwrites a newer version).
"""
import os
import re
import sys
import tempfile
try:
from reportlab.lib import colors
from reportlab.lib.enums import TA_LEFT
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import (
Flowable,
HRFlowable,
Image,
ListFlowable,
ListItem,
Paragraph,
Preformatted,
SimpleDocTemplate,
Spacer,
Table,
TableStyle,
)
except ImportError as exc:
sys.stderr.write("pdf_gen: missing dependency: %s (pip install reportlab pillow)\n" % exc)
sys.exit(3)
# ---------------------------------------------------------------- fonts ----
FONT_DIRS = [
os.path.join(os.environ.get("WINDIR", r"C:\Windows"), "Fonts"),
"/usr/share/fonts/truetype/",
"/usr/share/fonts/opentype/",
]
CJK_FONT_CANDIDATES = [
("SimSun", "simsun.ttc"), # 宋体 (Windows)
("SimSun", "simsun.ttf"),
("SimHei", "simhei.ttf"), # 黑体 (Windows)
("Microsoft YaHei", "msyh.ttc"),
("Noto Sans CJK SC", "NotoSansCJK-Regular.ttc"),
("Noto Sans CJK SC", "NotoSansCJKsc-Regular.otf"),
]
# Updated by register_cjk_fonts(): when a CJK font is available it replaces
# the ASCII-only built-ins so Chinese text is never dropped.
BODY_FONT = "Helvetica"
HEAD_FONT = "Helvetica-Bold"
CODE_FONT = "Courier"
def _find_font(name):
for d in FONT_DIRS:
path = os.path.join(d, name)
if os.path.exists(path):
return path
return None
def register_cjk_fonts():
"""Register a CJK font; updates the global font names when one works."""
global BODY_FONT, HEAD_FONT, CODE_FONT
for font_name, file_name in CJK_FONT_CANDIDATES:
path = _find_font(file_name)
if not path:
continue
try:
subfont = 0
while True:
try:
pdfmetrics.registerFont(TTFont(font_name, path, subfontIndex=subfont))
break
except Exception:
subfont += 1
if subfont > 8:
raise
except Exception:
continue
if BODY_FONT == "Helvetica" or font_name in ("SimSun", "Microsoft YaHei"):
BODY_FONT = font_name
if HEAD_FONT == "Helvetica-Bold":
HEAD_FONT = font_name
if CODE_FONT == "Courier":
CODE_FONT = font_name
return True
return False
def build_styles():
styles = getSampleStyleSheet()
base = dict(
fontName=BODY_FONT,
fontSize=10.5,
leading=15,
textColor=colors.black,
alignment=TA_LEFT,
spaceAfter=6,
wordWrap="CJK",
)
body = ParagraphStyle("md-body", **base)
h1 = ParagraphStyle("md-h1", parent=body, fontSize=17, leading=22, spaceBefore=14, spaceAfter=8)
h2 = ParagraphStyle("md-h2", parent=body, fontSize=14, leading=19, spaceBefore=10, spaceAfter=6)
h3 = ParagraphStyle("md-h3", parent=body, fontSize=12, leading=17, spaceBefore=8, spaceAfter=4)
quote = ParagraphStyle("md-quote", parent=body, leftIndent=14, textColor=colors.HexColor("#555555"), spaceAfter=8)
code = ParagraphStyle("md-code", parent=body, leftIndent=8, spaceAfter=8)
pre = ParagraphStyle("md-pre", parent=code, fontSize=8.5, leading=11.5, textColor=colors.HexColor("#1f2328"))
return {"body": body, "h1": h1, "h2": h2, "h3": h3, "quote": quote, "code": code, "pre": pre}
# --------------------------------------------------------------- outline ----
class OutlineEntry(Flowable):
"""Invisible flowable that registers a PDF outline/bookmark entry."""
def __init__(self, title, key, level):
super().__init__()
self.title = title
self.key = key
self.level = level
self.width = 0
self.height = 0
def draw(self):
pass
# reportlab >= 4.2 passes extra layout kwargs (e.g. `_sW`) to drawOn.
def drawOn(self, canvas, x, y, **_kwargs):
canvas.bookmarkPage(self.key)
canvas.addOutlineEntry(self.title, self.key, self.level, False)
# ------------------------------------------------------------- markdown ----
def xml_escape(text):
return (
text.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
)
def render_inline(text):
"""Convert inline markdown (bold/italic/code/links) to reportlab XML."""
out = []
i = 0
n = len(text)
emphasis = (
("***", "<b><i>", "</i></b>"),
("___", "<b><i>", "</i></b>"),
("**", "<b>", "</b>"),
("__", "<b>", "</b>"),
("*", "<i>", "</i>"),
("_", "<i>", "</i>"),
)
while i < n:
ch = text[i]
if ch == "`":
close = text.find("`", i + 1)
if close >= 0:
out.append('<font face="%s">%s</font>' % (CODE_FONT, xml_escape(text[i + 1:close])))
i = close + 1
continue
out.append(xml_escape(ch))
i += 1
continue
if ch == "[":
m = re.match(r"\[([^\]]*)\]\(([^)\s]+)\)", text[i:])
if m:
link_text = xml_escape(m.group(1))
href = m.group(2)
if href.startswith(("http://", "https://")):
out.append('<link href="%s" color="blue">%s</link>' % (xml_escape(href), link_text))
else:
out.append(link_text)
i += m.end()
continue
matched = False
for mark, open_tag, close_tag in emphasis:
if text.startswith(mark, i):
close = text.find(mark, i + len(mark))
if close >= 0:
out.append(open_tag + render_inline(text[i + len(mark):close]) + close_tag)
i = close + len(mark)
matched = True
break
out.append(xml_escape(mark))
i += len(mark)
matched = True
break
if not matched:
out.append(xml_escape(ch))
i += 1
return "".join(out)
def split_table_row(row):
row = row.strip()
if row.startswith("|"):
row = row[1:]
if row.endswith("|"):
row = row[:-1]
return [c.strip() for c in row.split("|")]
def parse_pipe_table(lines, idx):
"""Parse a GFM pipe table starting at lines[idx]; returns (rows, next_idx)."""
if "|" not in lines[idx]:
return None, idx
if idx + 1 >= len(lines) or not re.match(r"^\s*\|?[\s:|-]+\|?\s*$", lines[idx + 1]):
return None, idx
rows = [split_table_row(lines[idx])]
idx += 2
while idx < len(lines) and "|" in lines[idx]:
row = lines[idx].strip()
if not row or re.match(r"^\|?\s*:?-+:?\s*\|?$", row):
break
rows.append(split_table_row(row))
idx += 1
return rows, idx
def prepare_jpeg(src_path, work_dir):
"""Convert any image to a resized JPEG via Pillow; returns the new path."""
try:
from PIL import Image as PILImage
except ImportError:
return None
base = os.path.splitext(os.path.basename(src_path))[0] + ".jpg"
out = os.path.join(work_dir, base)
if os.path.exists(out):
return out
try:
im = PILImage.open(src_path)
im = im.convert("RGB")
if im.width > 900:
ratio = 900.0 / im.width
im = im.resize((900, max(1, int(im.height * ratio))), PILImage.LANCZOS)
im.save(out, "JPEG", quality=85)
return out
except Exception:
return None
def find_image_file(src, base_dir, images_dir):
"""Locate the original image file for a markdown image src.
The app rewrites every embedded image src to `images/img_N.ext` and copies
the files into `images_dir`. For bound documents `base_dir` may also point
at the .md's folder (relative original paths). The images_dir lookup by
basename must therefore run even when base_dir is absent (new/untitled
documents) — otherwise no image ever resolves there.
"""
if src.startswith(("http://", "https://", "data:")):
name = os.path.basename(src.split("?")[0]) or "image"
if images_dir:
for ext in (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg", ""):
cand = os.path.join(images_dir, os.path.splitext(name)[0] + ext)
if os.path.exists(cand):
return cand
return None
if base_dir:
cand = os.path.join(base_dir, src.replace("/", os.sep).replace("\\", os.sep))
if os.path.exists(cand):
return cand
if images_dir:
name = os.path.basename(src)
for ext in (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg", ""):
cand = os.path.join(images_dir, os.path.splitext(name)[0] + ext)
if os.path.exists(cand):
return cand
return None
def image_flowable(src, base_dir, images_dir, work_dir, alt):
"""Build an Image flowable, converting to JPEG first. None when missing."""
path = find_image_file(src, base_dir, images_dir)
if not path:
return None
jpg = prepare_jpeg(path, work_dir) or path
try:
img = Image(jpg)
img._restrictSize(150 * mm, 200 * mm)
return img
except Exception as exc:
sys.stderr.write("pdf_gen: image failed: %s\n" % exc)
return None
def build_pdf(md_text, output_path, base_dir, images_dir, include_images):
register_cjk_fonts()
styles = build_styles()
doc = SimpleDocTemplate(
output_path,
pagesize=A4,
leftMargin=18 * mm,
rightMargin=18 * mm,
topMargin=16 * mm,
bottomMargin=16 * mm,
title=os.path.splitext(os.path.basename(output_path))[0],
)
story = []
heading_count = 0
work_dir = tempfile.mkdtemp(prefix="md_pdf_")
lines = md_text.splitlines()
if lines and lines[0].strip() == "---":
end = next((i for i in range(1, len(lines)) if lines[i].strip() == "---"), None)
if end is not None:
lines = lines[end + 1:]
idx = 0
in_code = False
code_buf = []
list_items = []
def flush_list():
nonlocal list_items
if list_items:
items = [ListItem(Paragraph(render_inline(it), styles["body"])) for it in list_items]
story.append(ListFlowable(items, bulletType="bullet", start="•", leftIndent=12))
list_items = []
def push_image_block(src, alt):
if not include_images:
story.append(Paragraph("<i>[image link: %s]</i>" % xml_escape(src), styles["body"]))
return
img = image_flowable(src, base_dir, images_dir, work_dir, alt)
if img is not None:
story.append(img)
else:
story.append(Paragraph("<i>[image: %s]</i>" % xml_escape(alt or src), styles["body"]))
while idx < len(lines):
line = lines[idx].rstrip()
if in_code:
if line.strip().startswith("```") or line.strip().startswith("~~~"):
story.append(Preformatted("\n".join(code_buf), styles["pre"]))
code_buf = []
in_code = False
else:
code_buf.append(line)
idx += 1
continue
if line.strip().startswith("```") or line.strip().startswith("~~~"):
in_code = True
code_buf = []
idx += 1
continue
if not line.strip():
flush_list()
idx += 1
continue
m = re.match(r"^(#{1,3})\s+(.*)$", line)
if m:
flush_list()
level = len(m.group(1))
title = m.group(2).strip()
heading_count += 1
style = styles["h1"] if level == 1 else (styles["h2"] if level == 2 else styles["h3"])
story.append(OutlineEntry(title, "h%d" % heading_count, level - 1))
story.append(Paragraph(render_inline(title), style))
idx += 1
continue
rows, next_idx = parse_pipe_table(lines, idx)
if rows:
flush_list()
data = [[Paragraph(render_inline(c), styles["body"]) for c in row] for row in rows]
table = Table(data, repeatRows=1, hAlign="LEFT")
table.setStyle(TableStyle([
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#cccccc")),
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#f0f0f0")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 5),
("RIGHTPADDING", (0, 0), (-1, -1), 5),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
]))
story.append(table)
story.append(Spacer(1, 8))
idx = next_idx
continue
if line.strip() in ("---", "***", "___"):
flush_list()
story.append(HRFlowable(width="100%", thickness=0.6, color=colors.HexColor("#cccccc"), spaceBefore=6, spaceAfter=10))
idx += 1
continue
if line.startswith(">"):
flush_list()
quote_lines = []
while idx < len(lines) and (lines[idx].startswith(">") or not lines[idx].strip()):
if lines[idx].startswith(">"):
quote_lines.append(lines[idx][1:].lstrip())
elif quote_lines:
break
idx += 1
story.append(Paragraph(render_inline("\n".join(quote_lines)), styles["quote"]))
continue
if re.match(r"^\s*[-*+]\s+", line):
list_items.append(re.sub(r"^\s*[-*+]\s+", "", line))
idx += 1
continue
if re.match(r"^\s*\d+[.)]\s+", line):
list_items.append(re.sub(r"^\s*\d+[.)]\s+", "", line))
idx += 1
continue
img_m = re.match(r"^\s*!\[([^\]]*)\]\(([^)\s]+)\)\s*$", line)
if img_m:
flush_list()
push_image_block(img_m.group(2), img_m.group(1))
idx += 1
continue
# inline images inside a paragraph
if include_images and "![" in line:
flush_list()
parts = re.split(r"!\[([^\]]*)\]\(([^)\s]+)\)", line)
para_parts = []
i = 0
while i < len(parts):
if i % 3 == 0 and parts[i]:
para_parts.append(render_inline(parts[i]))
elif i % 3 == 1:
alt = parts[i]
src = parts[i + 1]
img = image_flowable(src, base_dir, images_dir, work_dir, alt)
if img is None:
para_parts.append("<i>[image]</i>")
else:
if para_parts:
story.append(Paragraph("".join(para_parts), styles["body"]))
para_parts = []
story.append(img)
i += 2
continue
i += 1
if para_parts:
story.append(Paragraph("".join(para_parts), styles["body"]))
idx += 1
continue
flush_list()
story.append(Paragraph(render_inline(line), styles["body"]))
idx += 1
flush_list()
if in_code and code_buf:
story.append(Preformatted("\n".join(code_buf), styles["pre"]))
doc.build(story)
for f in os.listdir(work_dir):
try:
os.remove(os.path.join(work_dir, f))
except OSError:
pass
try:
os.rmdir(work_dir)
except OSError:
pass
return True
def main(argv):
if len(argv) < 3:
sys.stderr.write("usage: python pdf_gen.py <input.md> <output.pdf> [--base <dir>] [--images <dir>] [--no-images]\n")
return 2
md_path = argv[1]
out_path = argv[2]
base_dir = None
images_dir = None
include_images = True
i = 3
while i < len(argv):
if argv[i] == "--base" and i + 1 < len(argv):
base_dir = argv[i + 1]
i += 2
elif argv[i] == "--images" and i + 1 < len(argv):
images_dir = argv[i + 1]
i += 2
elif argv[i] == "--no-images":
include_images = False
i += 1
else:
i += 1
if not os.path.exists(md_path):
sys.stderr.write("pdf_gen: input not found: %s\n" % md_path)
return 2
with open(md_path, "r", encoding="utf-8") as fh:
md_text = fh.read()
build_pdf(md_text, out_path, base_dir, images_dir, include_images)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))