Как конвертировать CHM в один PDF на Linux: без мусора и битых ссылок
Source: Dev.to
Если у тебя есть bsm_api.chm и нужно получить один нормальный PDF, а не «папку из 900 HTML + слёзы», вот рабочий пайплайн:
CHM → HTML → (сборка) → PDF
В итоге будет:
- один
manual.pdf; - без битых внутренних ссылок (насколько позволяет исходник);
- с нормальной навигацией/оглавлением (если CHM адекватный).
В чём проблема
CHM — это не «один файл с текстом», а контейнер: HTML + картинки + оглавление + внутренние ссылки.
Типовые боли:
| Проблема | Почему происходит |
|---|---|
| Конвертеры делают 1000 страниц вместо одного PDF | Обрабатывают каждый HTML‑файл отдельно |
| Ломают ссылки и кодировки | Не учитывают относительные пути и разные charset |
| PDF выглядит как скриншоты из 2007 | Конвертируют растровые изображения вместо текста |
Рабочее решение
1️⃣ Установка инструментов
Ubuntu/Debian
sudo apt update
sudo apt install -y \
extractchm \
wkhtmltopdf \
python3 \
python3-bs4 \
python3-lxml
Если extractchm нет (редко, но бывает), ставим из chmlib:
sudo apt install -y chmlib-tools # даст утилиту extract_chmLib
Проверка установки
extractchm -h || true
wkhtmltopdf --version
2️⃣ Распаковать CHM в HTML
# Создай рабочую папку
mkdir -p ~/work/chm2pdf/{src,html,out}
cp bsm_api.chm ~/work/chm2pdf/src/
cd ~/work/chm2pdf
Вариант A (extractchm)
extractchm -o html src/bsm_api.chm
Вариант B (extract_chmLib)
extract_chmLib src/bsm_api.chm html
Проверка, что распаковалось
ls -la html | head
find html -maxdepth 2 -type f | head
3️⃣ Найти «точку входа» (главную страницу)
Обычно это index.html, default.html, start.html или что‑то из оглавления.
ls html | grep -iE 'index|default|start|main' || true
Если не очевидно — ищем самый «толстый» HTML:
find html -type f -iname '*.html' -printf '%s\t%p\n' | sort -n | tail -20
4️⃣ Собрать единый HTML (важный шаг)
wkhtmltopdf лучше работает, когда получает одну HTML‑страницу, а не тысячу.
Ниже – минимальный Python‑скрипт, который:
- читает оглавление (если найдёт файл типа
.hhc/toc); - если оглавления нет — склеивает HTML по алфавиту (хуже, но работает);
- вырезает мусорные
<script>,<iframe>и т.п.; - приводит относительные ссылки к локальным путям.
tools/merge.py
#!/usr/bin/env python3
import os
import re
from pathlib import Path
from bs4 import BeautifulSoup
ROOT = Path("html").resolve()
OUT = Path("out")
OUT.mkdir(parents=True, exist_ok=True)
def list_html_files():
"""
Универсальный fallback — склеим HTML по имени.
При желании можно добавить парсер .hhc/.hhk.
"""
files = sorted(ROOT.rglob("*.html"))
# отбрасываем мелкие служебные файлы
files = [p for p in files if p.is_file() and p.stat().st_size > 200]
return files
def clean_body(html_path: Path) -> str:
data = html_path.read_bytes()
# пробуем несколько кодировок
for enc in ("utf-8", "cp1251", "windows-1251", "latin-1"):
try:
text = data.decode(enc)
break
except UnicodeDecodeError:
continue
else:
text = data.decode("utf-8", "ignore")
soup = BeautifulSoup(text, "lxml")
# выкидываем потенциальный мусор
for tag in soup(["script", "iframe", "noscript"]):
tag.decompose()
body = soup.body or soup
# нормализуем якоря и локальные ресурсы
for a in body.find_all("a", href=True):
href = a["href"].strip()
# убираем внешние ссылки и mailto
if href.startswith(("http://", "https://", "mailto:")):
continue
a["href"] = href.replace("\\", "/") # windows‑style слеши
# добавляем заголовок‑разделитель
title = soup.title.get_text(strip=True) if soup.title else html_path.name
header = f"\n## {title}\n\n"
return header + str(body)
def main():
parts = []
for p in list_html_files():
try:
parts.append(clean_body(p))
except Exception as e:
print(f"skip {p}: {e}")
merged = """
CHM merged
body { font-family: Arial, sans-serif; line-height: 1.45; }
pre, code { white-space: pre-wrap; word-wrap: break-word; }
h1 { page-break-before: always; }
""" + "".join(parts) + """
"""
(OUT / "merged.html").write_text(merged, encoding="utf-8")
print("OK -> out/merged.html")
if __name__ == "__main__":
main()
Запуск скрипта
mkdir -p tools out
python3 tools/merge.py
ls -la out/merged.html
5️⃣ Конвертировать merged.html → PDF
wkhtmltopdf \
--enable-local-file-access \
--encoding utf-8 \
--page-size A4 \
--margin-top 12 --margin-bottom 12 \
--margin-left 10 --margin-right 10 \
out/merged.html out/manual.pdf
Проверка результата
ls -lah out/manual.pdf
file out/manual.pdf
Быстрый sanity‑check: извлечь пару строк текста из PDF.
python3 - <<'PY'
import subprocess
p = subprocess.run(
["pdftotext", "out/manual.pdf", "-"],
capture_output=True, text=True
)
print(p.stdout[:800])
PY
Если pdftotext отсутствует:
sudo apt install -y poppler-utils
Типичные ошибки и их решения
| Ошибка | Причина | Решение |
|---|---|---|
❌ Blocked access to file / картинки не видны | wkhtmltopdf по умолчанию блокирует локальные ресурсы | Добавь --enable-local-file-access (см. выше) |
| ❌ Кракозябры вместо русского | HTML в CP1251, а склеили как UTF‑8 | В merge.py уже пробуем cp1251/windows-1251. Если всё равно плохо – проверь реальную кодировку: `file -i html/*.html |
| ❌ Оглавление отсутствует, порядок глав сломан | CHM хранит структуру в .hhc/.hhk, а мы склеили «по алфавиту» | Напиши парсер оглавления под конкретный CHM (обычно 20‑40 строк). Если нужна помощь – пришли список файлов из html/, подскажу точечно. |
Где применять
- Локально – быстро получить PDF, отдать в LLM/коллегам/заказчику.
- CI/CD – автогенерация документации в PDF (если CHM выступает артефактом).
- VPS – можно крутить без GUI, чисто консолью.
Смотрите также
- Поиск файлов в Linux: команды для Ubuntu/CentOS — диагностика и
find. find: поиск больших файлов — когда PDF внезапно «взросл».
Если понадобится доработать скрипт под конкретный CHM – дайте знать, помогу адаптировать парсер оглавления.
rsync: безопасное копирование с прогрессом — перенос распакованного CHM или готового PDF