233 lines
5.9 KiB
Bash
Executable File
233 lines
5.9 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# pdf2md.sh — 将 PDF 文件转换为结构化 Markdown
|
|
# 依赖: poppler-utils (pdftotext)
|
|
# 用法: pdf2md.sh <input.pdf> [-o output.md] [-f N] [-l N] [--raw] [--no-toc]
|
|
|
|
set -euo pipefail
|
|
|
|
# === 默认值 ===
|
|
INPUT=""
|
|
OUTPUT=""
|
|
FIRST_PAGE=""
|
|
LAST_PAGE=""
|
|
RAW_MODE=false
|
|
NO_TOC=false
|
|
|
|
# === 参数解析 ===
|
|
usage() {
|
|
cat <<'EOF'
|
|
用法: pdf2md.sh <input.pdf> [选项]
|
|
|
|
选项:
|
|
-o FILE 输出文件路径 (默认: 同名 .md, "-" 表示 stdout)
|
|
-f N 起始页码
|
|
-l N 结束页码
|
|
--raw 跳过后处理,输出原始文本
|
|
--no-toc 不生成目录
|
|
-h, --help 显示帮助信息
|
|
|
|
示例:
|
|
pdf2md.sh doc.pdf
|
|
pdf2md.sh doc.pdf -o output.md
|
|
pdf2md.sh doc.pdf -f 1 -l 10
|
|
pdf2md.sh doc.pdf --raw -o -
|
|
EOF
|
|
exit 0
|
|
}
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
-o) OUTPUT="$2"; shift 2 ;;
|
|
-f) FIRST_PAGE="$2"; shift 2 ;;
|
|
-l) LAST_PAGE="$2"; shift 2 ;;
|
|
--raw) RAW_MODE=true; shift ;;
|
|
--no-toc) NO_TOC=true; shift ;;
|
|
-h|--help) usage ;;
|
|
-*) echo "未知选项: $1" >&2; exit 1 ;;
|
|
*)
|
|
if [[ -z "$INPUT" ]]; then
|
|
INPUT="$1"
|
|
else
|
|
echo "错误: 多个输入文件" >&2; exit 1
|
|
fi
|
|
shift
|
|
;;
|
|
esac
|
|
done
|
|
|
|
# === 校验输入 ===
|
|
if [[ -z "$INPUT" ]]; then
|
|
echo "错误: 未指定输入 PDF 文件" >&2
|
|
usage
|
|
fi
|
|
|
|
if [[ ! -f "$INPUT" ]]; then
|
|
echo "错误: 文件不存在: $INPUT" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if ! command -v pdftotext &>/dev/null; then
|
|
echo "错误: pdftotext 未安装。请运行: sudo apt-get install poppler-utils" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# === 确定输出路径 ===
|
|
if [[ -z "$OUTPUT" ]]; then
|
|
OUTPUT="${INPUT%.pdf}.md"
|
|
fi
|
|
|
|
# === 构建 pdftotext 命令 ===
|
|
PDFTOTEXT_ARGS=(-layout -enc UTF-8 -eol unix -nopgbrk)
|
|
[[ -n "$FIRST_PAGE" ]] && PDFTOTEXT_ARGS+=(-f "$FIRST_PAGE")
|
|
[[ -n "$LAST_PAGE" ]] && PDFTOTEXT_ARGS+=(-l "$LAST_PAGE")
|
|
|
|
# === 提取文本 ===
|
|
RAW_TEXT=$(pdftotext "${PDFTOTEXT_ARGS[@]}" "$INPUT" - 2>/dev/null) || {
|
|
echo "错误: pdftotext 执行失败" >&2
|
|
exit 1
|
|
}
|
|
|
|
# === 原始模式直接输出 ===
|
|
if [[ "$RAW_MODE" == true ]]; then
|
|
if [[ "$OUTPUT" == "-" ]]; then
|
|
echo "$RAW_TEXT"
|
|
else
|
|
echo "$RAW_TEXT" > "$OUTPUT"
|
|
echo "已输出原始文本: $OUTPUT"
|
|
fi
|
|
exit 0
|
|
fi
|
|
|
|
# === 后处理: 转换为结构化 Markdown ===
|
|
# 使用 Python 做后处理(更可靠的正则和文本分析)
|
|
RESULT=$(python3 -c "
|
|
import sys
|
|
import re
|
|
|
|
def is_list_item(line):
|
|
'''检测是否为列表项'''
|
|
return bool(re.match(r'^[-\u2022*]\s', line) or re.match(r'^\d+[.)]\s', line))
|
|
|
|
def is_separator(line):
|
|
'''检测是否为分隔线'''
|
|
return bool(re.match(r'^[-=_*]{3,}$', line))
|
|
|
|
def is_all_upper(line):
|
|
'''检测是否全大写(允许数字、标点、空格)'''
|
|
stripped = re.sub(r'[A-Z0-9\s:._\-/\\()&,;!?\x27\x22]', '', line)
|
|
return len(stripped) == 0 and len(line) > 3
|
|
|
|
def process(text):
|
|
lines = text.split('\n')
|
|
output = []
|
|
toc = []
|
|
prev_blank = True
|
|
in_code = False
|
|
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
length = len(stripped)
|
|
leading = len(line) - len(line.lstrip())
|
|
|
|
# 空行
|
|
if length == 0:
|
|
if in_code:
|
|
output.append('\`\`\`')
|
|
in_code = False
|
|
prev_blank = True
|
|
continue
|
|
|
|
# --- 代码块检测: 连续缩进 >= 4 空格 ---
|
|
if leading >= 4 and not in_code:
|
|
# 检查前一行是否也是缩进的(或当前已连续)
|
|
output.append('\`\`\`')
|
|
output.append(line[4:])
|
|
in_code = True
|
|
prev_blank = False
|
|
continue
|
|
elif in_code and leading >= 4:
|
|
output.append(line[4:])
|
|
prev_blank = False
|
|
continue
|
|
elif in_code and leading < 4:
|
|
output.append('\`\`\`')
|
|
in_code = False
|
|
|
|
# --- 列表项 (优先级高于标题检测) ---
|
|
if is_list_item(stripped):
|
|
# 将 bullet 统一为 -
|
|
fixed = re.sub(r'^[\u2022*]\s', '- ', stripped)
|
|
output.append(fixed)
|
|
prev_blank = False
|
|
continue
|
|
|
|
# --- 分隔线 ---
|
|
if is_separator(stripped):
|
|
output.append('')
|
|
output.append('---')
|
|
prev_blank = True
|
|
continue
|
|
|
|
# --- 表格检测: 含 | 或连续制表符 ---
|
|
if '|' in stripped or (leading > 0 and '\t' in stripped):
|
|
output.append(stripped)
|
|
prev_blank = False
|
|
continue
|
|
|
|
# --- 标题检测 ---
|
|
# 条件: 前有空行 + 短行(<=60字符) + 非列表项 + 非分隔线
|
|
if prev_blank and 3 < length <= 80:
|
|
if is_all_upper(stripped):
|
|
# 全大写 → ## 级标题
|
|
title = stripped
|
|
output.append('')
|
|
output.append(f'## {title}')
|
|
toc.append(f'- {title}')
|
|
prev_blank = False
|
|
continue
|
|
elif length <= 60:
|
|
# 短行 + 前空行 → ### 级标题
|
|
output.append('')
|
|
output.append(f'### {stripped}')
|
|
prev_blank = False
|
|
continue
|
|
|
|
# --- 普通文本 ---
|
|
output.append(stripped)
|
|
prev_blank = False
|
|
|
|
if in_code:
|
|
output.append('\`\`\`')
|
|
|
|
return output, toc
|
|
|
|
raw = sys.stdin.read()
|
|
out_lines, toc = process(raw)
|
|
|
|
# 文档标题
|
|
filename = '${INPUT##*/}'
|
|
filename = filename.rsplit('.', 1)[0]
|
|
print(f'# {filename}')
|
|
print()
|
|
|
|
# 目录
|
|
if toc and len(toc) > 0:
|
|
print('## 目录')
|
|
print()
|
|
for item in toc:
|
|
print(item)
|
|
print()
|
|
|
|
# 正文
|
|
for line in out_lines:
|
|
print(line)
|
|
" <<< "$RAW_TEXT")
|
|
|
|
# === 写入输出 ===
|
|
if [[ "$OUTPUT" == "-" ]]; then
|
|
echo "$RESULT"
|
|
else
|
|
echo "$RESULT" > "$OUTPUT"
|
|
echo "已生成: $OUTPUT ($(wc -l < "$OUTPUT") 行)"
|
|
fi
|