#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""专题级源文件定位 —— 给「预览/下载本专题源文件」提供统一入口。

## 为什么需要
五个教材的源文件粒度不同：
  初中培优讲义  1 专题 = 1 个 docx     （天然就是专题级）
  其余四个      1 册   = 1 个整本 PDF   （要按页范围切出专题）
本模块把差异吃掉，对外只暴露两个函数：

  locate(tb, gg, cd) -> {'kind','pages':[本地png路径...],'pdf':(整本pdf, 起页, 止页),'docx':路径}
  cut_pdf(src, lo, hi, out)  用 qpdf 切页（0.05 秒级，无第三方依赖）

## 页范围从哪来（全部现成，不新建）
  动脑筋/举一反三  组卷系统/原书页/page_ranges.json（dnj 228 专题 / jyf 357 专题）
  一课一练        每题带 page(p007)+lesson，按课聚合取 min~max
  胡小群课程      每题带 sec(讲号)，按讲聚合
  初中培优讲义    data/v2/units.json 的 docx + data/v2/pages/<key>/*.png
"""
import os, re, json, glob, shutil, hashlib, subprocess, collections

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)                       # 课程设计机器人/
SRCDIR = os.path.join(ROOT, '源题文件')

_cache = {}


def _j(p):
    try:
        return json.load(open(p, encoding='utf-8'))
    except Exception:
        return None


# ── 初中培优讲义 ────────────────────────────────────────────────
def _v2_root():
    for up in ('..', '../..', '.'):
        c = os.path.normpath(os.path.join(HERE, up, '数学学习系统', 'data', 'v2'))
        if os.path.exists(os.path.join(c, 'units.json')):
            return c
    return None


def _v2_units():
    if 'v2' not in _cache:
        V2 = _v2_root()
        idx = {}
        if V2:
            for u in (_j(os.path.join(V2, 'units.json')) or []):
                idx[(u.get('title') or '').strip()] = u
        _cache['v2'] = (V2, idx)
    return _cache['v2']


# ── 动脑筋 / 举一反三：page_ranges.json ─────────────────────────
_PGN = {'一年级': 1, '二年级': 2, '三年级': 3, '四年级': 4, '五年级': 5, '六年级': 6}


def _ranges():
    if 'pr' not in _cache:
        _cache['pr'] = _j(os.path.join(HERE, '原书页', 'page_ranges.json')) or {}
    return _cache['pr']


def _book_pdf(kind, grade_no):
    """整本 PDF 路径：源题文件/<动脑筋|举一反三>/…<N>年级…pdf"""
    key = {'dnj': '动脑筋', 'jyf': '举一反三'}[kind]
    cn = '一二三四五六'[grade_no - 1]
    for p in glob.glob(os.path.join(SRCDIR, key, '*.pdf')):
        if f'{cn}年级' in os.path.basename(p):
            return p
    return None


# ── 一课一练 / 胡小群：按题聚合页号 ─────────────────────────────
def _ykl_index():
    """{(年级册名, 课名): (minPage, maxPage, 结构化json所在目录名)}"""
    if 'ykl' not in _cache:
        idx = {}
        for f in glob.glob(os.path.join(ROOT, '成果', '一课一练题库', '*', '*.json')):
            book = os.path.basename(os.path.dirname(f))
            d = _j(f)
            qs = d if isinstance(d, list) else (d or {}).get('questions') or []
            agg = collections.defaultdict(list)
            for q in qs:
                les = (q.get('lesson') or '').strip()
                pg = str(q.get('page') or '')
                m = re.match(r'p?(\d+)', pg)
                if les and m:
                    agg[les].append(int(m.group(1)))
            for les, ps in agg.items():
                idx[(book, les)] = (min(ps), max(ps))
        _cache['ykl'] = idx
    return _cache['ykl']


def _resolve_rel(rel):
    """把题目 pg 里的相对路径解析成真实文件。

    ⚠ 随站的 组卷系统/原书页/ 只带了 dnj、jyf 两套；一课一练(ykl)那套没随站，
      要回退到仓库原始 成果/一课一练题库/<册目录>/pages/（服务端已有同样的回退思路）。
    """
    fp = os.path.join(HERE, rel)
    if os.path.exists(fp):
        return fp
    m = re.match(r'原书页/ykl/([^/]+)/(p\d+\.png)$', rel)
    if m:
        rk, name = m.groups()
        YKLD = {'1s': '一年级上普通2024秋', '1sz': '一年级上增强2024秋',
                '2s': '二年级上普通', '2sz': '二年级上增强',
                '3s': '三年级上', '3sz': '三年级上增强2022秋',
                '3x': '三年级下普通2022秋', '3xz': '三年级下增强2022秋',
                '4s': '四年级上普通2026秋', '4sz': '四年级上增强2026秋',
                '4x': '四年级下普通2023春', '4xz': '四年级下增强2023春',
                '5s': '五年级上普通2026秋', '5sz': '五年级上增强2026秋',
                '5x': '五年级下普通2023春', '5xz': '五年级下增强2023春',
                '6s': '六年级上普通2024秋'}
        book = YKLD.get(rk)
        if book:
            p = os.path.join(ROOT, '成果', '一课一练题库', book, 'pages', name)
            if os.path.exists(p):
                return p
    return None


def locate_by_questions(pgs):
    """通用兜底：拿该专题**所有题的 pg 字段并集**当页范围。

    每道题的 pg 已经是「该题所在页 ±1」的页图相对路径（组卷台既有的「对照原书」用的就是它），
    所以专题级页范围 = 组内所有题 pg 的并集 —— 不需要另建映射表，任何教材都通用。
    → {'pages': [绝对路径…], 'span': (最小页, 最大页, 相对目录)}
    """
    nums, sub, ext = [], None, 'png'
    for rel in sorted(set(pgs)):
        m = re.search(r'p(\d+)\.(png|jpg)$', rel)
        if m:
            nums.append(int(m.group(1))); sub = os.path.dirname(rel); ext = m.group(2)
    if not nums:
        return None
    # ⚠ 预览要给**连续页范围**（min~max），不能只给 pg 里出现过的离散页：
    #   下载是按 min~max 连续切的，两边口径必须一致；中间页也是这个专题的内容。
    files = []
    for n in range(min(nums), max(nums) + 1):
        fp = _resolve_rel(f'{sub}/p{n:03d}.{ext}')
        if fp:
            files.append(fp)
    if not files:
        return None
    return {'pages': files, 'span': (min(nums), max(nums), sub)}


def locate(tb, gg, cd, src_hint=''):
    """→ dict 或 None。kind ∈ {v2, dnj, jyf, ykl, hxq}
    src_hint = 题目的 src 字段（胡小群靠它判册次，如「三上讲义」→ 三上）。"""
    cd = (cd or '').strip()
    if tb == '初中培优讲义':
        V2, idx = _v2_units()
        u = idx.get(cd)
        if not u:
            return None
        pngs = sorted(glob.glob(os.path.join(V2, 'pages', u['key'], 'p*.png')))
        return {'kind': 'v2', 'title': cd, 'pages': pngs,
                'docx': u.get('docx'), 'pdf': None}
    if tb in ('动脑筋', '举一反三'):
        kind = 'dnj' if tb == '动脑筋' else 'jyf'
        g = _PGN.get(gg)
        if not g:
            return None
        # cd 形如 "专题1 …"；页范围表按专题序号索引
        m = re.search(r'(\d+)', cd)
        if not m:
            return None
        r = (_ranges().get('ranges', {}).get(kind, {})
             .get(str(g), {}).get(m.group(1)))
        if not r:
            return None
        lo, hi = r
        book = _book_pdf(kind, g)
        pdir = os.path.join(HERE, '原书页', kind, str(g))
        pngs = [os.path.join(pdir, f'p{n:03d}.jpg') for n in range(lo, hi + 1)]
        pngs = [p for p in pngs if os.path.exists(p)]
        return {'kind': kind, 'title': cd, 'pages': pngs,
                'pdf': (book, lo, hi) if book else None, 'docx': None}
    if tb == '胡小群课程':
        return _hxq(gg, cd, src_hint)
    return None


def _hxq_index():
    """{(册, 讲标题): [页号…]} —— 从 成果/胡小群讲义/讲义例题_题目.json 按 sec 聚合。
    该库每题带 book(如「三上」)+ sec(讲号)+ sec_title，页图在 成果/胡小群讲义/pages/<册>/。"""
    if 'hxq' not in _cache:
        idx = {}
        d = _j(os.path.join(ROOT, '成果', '胡小群讲义', '讲义例题_题目.json')) or {}
        for it in (d.get('items') or []):
            book = (it.get('book') or '').strip()
            t = (it.get('sec_title') or '').strip()
            if book and t:
                idx.setdefault((book, t), set()).add(it.get('sec'))
        _cache['hxq'] = idx
    return _cache['hxq']


HXQ_HW = os.path.join(SRCDIR, '胡小群数学L0-L6', '课后习题', '习题+解析')


def _hxq(gg, cd, src_hint=''):
    """胡小群分两类源：
      「三上讲义」等 → 讲义习题/<册>胡小群.pdf + 成果/胡小群讲义/pages/<册>/
      「1-2年级课后」 → 课后习题/习题+解析/<N-M讲>/习题N：<讲名>.pdf（**一讲一个 PDF，天然专题级**）
      「L3~L6课后」   → 课后习题/习题+解析/L<N>-习题集.pdf（整本，无讲级页范围 → 只能给整本）
    """
    sh = (src_hint or '').strip()
    if '课后' in sh:
        # ① 1-2年级：按讲名精确匹配单讲 PDF
        for sub in sorted(glob.glob(os.path.join(HXQ_HW, '*讲'))):
            for p in glob.glob(os.path.join(sub, '习题*.pdf')):
                b = os.path.basename(p)
                if '答案' in b:
                    continue
                name = re.sub(r'^习题\d+[：:]', '', b).split('【')[0].strip()
                if name and (name == cd or name in cd or cd in name):
                    return {'kind': 'hxq_hw', 'title': cd, 'pages': [],
                            'docx': None, 'pdf': (p, 1, 0), 'whole_file': True}
        # ② L3~L6：整本习题集
        m = re.match(r'L(\d)', sh)
        if m:
            p = os.path.join(HXQ_HW, f'L{m.group(1)}-习题集.pdf')
            if os.path.exists(p):
                return {'kind': 'hxq_hw', 'title': cd, 'pages': [],
                        'docx': None, 'pdf': (p, 1, 0), 'whole_file': True}
        return None
    m = re.match(r'([一二三四五六])([上下])', sh)
    if not m:
        return None
    book = m.group(1) + m.group(2)
    pdir = os.path.join(ROOT, '成果', '胡小群讲义', 'pages', book)
    pngs = sorted(glob.glob(os.path.join(pdir, 'p*.png')))
    if not pngs:
        return None
    # ⚠ 源文件名是「四年级下胡小群.pdf」/「六年级上册胡小群.pdf」，不是「四下」——
    #   要按 <年级字><年级>[册]<上下> 匹配，直接找 book("四下") 会全部落空。
    g, term = book[0], book[1]
    src_pdf = None
    for base in ('胡小群数学L0-L6', '胡小群数学L7-L9、综合'):
        for p in glob.glob(os.path.join(SRCDIR, base, '**', '*.pdf'), recursive=True):
            b = os.path.basename(p)
            if b.startswith(f'{g}年级') and term in b[:6]:
                src_pdf = p; break
        if src_pdf:
            break
    return {'kind': 'hxq', 'title': cd, 'pages': pngs, 'docx': None,
            'pdf': (src_pdf, 1, len(pngs)) if src_pdf else None, 'whole_book': True}


# ── 预拆分成品：源题文件/_专题拆分/（split_topics.py 生成，build_precut_map.py 对号）──
PRECUT = os.path.join(SRCDIR, '_专题拆分')


def _precut_map():
    """{'教材|年级|专题': [{'label','path'}…]} —— 组卷台专题节 → 拆好的成品文件。
    带 mtime 失效，重跑 build_precut_map.py 后不用重启服务。"""
    p = os.path.join(PRECUT, '_组卷台映射.json')
    try:
        mt = os.path.getmtime(p)
    except OSError:
        return {}, {}
    if _cache.get('pcmt') != mt:
        d = _j(p) or {}
        idx = {}
        for it in (_j(os.path.join(PRECUT, '_索引.json')) or {}).get('items', []):
            idx[it['out']] = it
        _cache['pcmt'] = mt
        _cache['pc'] = (d.get('map') or {}, idx)
    return _cache['pc']


def precut(tb, gg, cd, src_hint='', role=None, label=None):
    """→ {'path':绝对路径,'pages':[起页,止页]|None,'label':…,'role':…} 或 None

    一个专题节下可能挂着多份：学生版/教师版（很多教材两版都有），
    胡小群还再分讲义/课后。先按 role 过滤，再按题目的 src 字段选；
    选不出就给第一个（学生版排在前，默认给学生的那份）。
    """
    m, idx = _precut_map()
    hits = m.get(f'{tb}|{gg}|{cd}')
    if not hits:
        return None
    if label:                       # 前端点的是哪一行就给哪一份，**精确匹配不猜**
        exact = [h for h in hits if h.get('label') == label]
        if exact:
            hits = exact
    elif role:
        hits = [h for h in hits if h.get('role') == role] or hits
    pick = hits[0]
    sh = src_hint or ''
    for h in hits:
        if h['label'] and h['label'] in sh:
            pick = h; break
    fp = os.path.join(SRCDIR, pick['path'])
    if not os.path.exists(fp):
        return None
    it = idx.get(pick['path']) or {}
    return {'path': fp, 'label': pick['label'], 'role': pick.get('role', '学生版'),
            'pages': it.get('pages'), 'tb': it.get('tb'), 'book': it.get('book')}


# 各教材已渲好的页图目录（预览优先用它，比 pdftoppm 现渲又快又清楚）
def _pagedir(tb, book):
    """→ (目录, 页号位数) 或 None"""
    if tb == '一课一练':
        return os.path.join(ROOT, '成果', '一课一练题库', book, 'pages'), 3
    if tb == '胡小群讲义':
        return os.path.join(ROOT, '成果', '胡小群讲义', 'pages', book), 2
    return None


# 这些教材的成品文件**不是从题目所在那一册切出来的**，页图兜底一律不能用。
# 胡小群课后是从「课后习题集 / 解析版」切的，页号跟讲义册风马牛不相及 ——
# 踩过：四年级·相遇问题 课后 pages=[16,17]，被拿去讲义页图目录切片，
# 预览出来是讲义第 16、17 页（讲义预览是对的，因为它走的是已渲页图那条）。
# 这类只能从成品 PDF 现渲（2~3 页，pdftoppm 0.15 秒/页且带缓存），保证预览=下载。
#（胡小群讲义答案虽然就切自讲义册，但册尾那 2~3 页不在已渲页图范围内 —— 讲义只渲到
#  最后一讲，答案页没渲 —— 同样只能现渲。）
_XBOOK_PAGES = {'胡小群课后', '胡小群讲义答案'}

def _same_file(a, b):
    """是不是同一个文件（硬链接也算）—— 判「这批页图配不配得上这份成品」用。"""
    try:
        return bool(a) and bool(b) and os.path.samefile(a, b)
    except Exception:
        return False


def precut_all(tb, gg, cd):
    """该专题节挂着的全部成品文件（胡小群会同时有「讲义」和「课后」）→ [{'label','path'}…]"""
    m, _ = _precut_map()
    return [h for h in (m.get(f'{tb}|{gg}|{cd}') or [])
            if os.path.exists(os.path.join(SRCDIR, h['path']))]


def precut_pages(pc, pgs=None, loc=None):
    """预览用页图，**必须是这个成品文件里的那几页**（预览什么=下载什么）。

    优先复用已渲染好的高清页图（扫描件 200dpi，比现渲清楚），
    实在找不到才用 pdftoppm 从成品 PDF 现渲。
    """
    rng = pc.get('pages')
    xbook = pc.get('tb') in _XBOOK_PAGES     # 页图兜底对它无效，见 _XBOOK_PAGES
    if rng:
        lo, hi = rng
        # ① 拆分索引自己就知道这册的页图在哪 —— **不依赖前端传不传 pg**。
        #    （踩过：只认 pgs 时，pg 为空就退化成 pdftoppm 逐页现渲，800 个专题能把机器压垮）
        pd = _pagedir(pc.get('tb'), pc.get('book') or '')
        if pd:
            d, w = pd
            files = [os.path.join(d, f'p{n:0{w}d}.png') for n in range(lo, hi + 1)]
            files = [f for f in files if os.path.exists(f)]
            if len(files) == hi - lo + 1:
                return files
        # ② 顺着前端传来的题目页图路径，拿到该册的页图目录，取 lo~hi
        sub = None
        for rel in ([] if xbook else (pgs or [])):
            m = re.search(r'^(.*)/p\d+\.(png|jpg)$', rel)
            if m:
                sub, ext = m.group(1), m.group(2); break
        if sub:
            files = []
            for n in range(lo, hi + 1):
                f = _resolve_rel(f'{sub}/p{n:03d}.{ext}')
                if f:
                    files.append(f)
            if len(files) == hi - lo + 1:
                return files
        # ② 教材自带的整册页图列表里按页号切片（胡小群讲义走这条）
        if loc and loc.get('pages') and not xbook:
            files = []
            for f in loc['pages']:
                m = re.search(r'p(\d+)\.(png|jpg)$', os.path.basename(f))
                if m and lo <= int(m.group(1)) <= hi:
                    files.append(f)
            if files:
                return files
    elif (loc and loc.get('pages') and not loc.get('whole_book') and not xbook
          and (_same_file(pc['path'], loc.get('docx')) if loc.get('docx')
               else pc.get('role', '学生版') == '学生版')):
        # 索引里没记页范围（动脑筋/举一反三是把官方拆分版硬链进来的），但教材本身
        # 就有该专题的页图（page_ranges 那套）—— 页数已核过与成品文件一致，直接用。
        #
        # 两种教材两套判据，因为「这批页图配不配得上这份成品」的依据不一样：
        #  · 初中培优（loc 有 docx）：页图就是照那个 docx 渲的，**同一个文件才算数**
        #    （用 inode 判，硬链接也认）。踩过两个坑：
        #      ① units.json 指的是「（解析版）」，页图带红字【答案】【分析】【解析】，
        #         原先无脑给「学生版」那行 —— 老师点学生版预览看到的是答案版，
        #         点下载拿到的却是原卷版（37 页 vs 16 页，页数都对不上）；
        #      ② 七下两份期末模拟卷有三个版本，题库挑的和拆分挑的不是同一份。
        #    对不上的那份就走下面 docx→PDF 现渲，慢 6 秒但**一定是它自己**。
        #  · 动脑筋/举一反三（loc 无 docx）：页图是该专题的学生页，成品是官方拆分版，
        #    两者不同文件但页数已核过一致 —— **只对学生版成立**，教师版是「整册参考答案」
        #    (18~26页)，拿专题学生页图(2~5页)当预览又破了「预览什么就下载什么」。踩过。
        return loc['pages']
    if pc['path'].lower().endswith('.pdf'):
        return render_pdf_pages(pc['path'], 1, 0)
    if pc['path'].lower().endswith(('.docx', '.doc')):
        return render_docx_pages(pc['path'])
    return []


def render_docx_pages(src, cache_dir=None):
    """docx → PDF（LibreOffice）→ 页图。初中培优的成品只有 docx，没有现成页图。

    首次约 6 秒，之后走缓存。
    ⚠ soffice 的 file:// URL 碰到**中文或空格**就崩，而且崩的是**整条路径**不只文件名 ——
      本仓库路径是「…/AI workspace/课程设计机器人/组卷系统/…」，两样全占。
      所以整个转换在纯 ASCII 的临时目录里做完，再把 PDF 搬回缓存（踩过：只换文件名不管用，
      rc=0 但一个文件都不产出）。独立 UserInstallation 是为了不跟用户开着的 LibreOffice 抢锁。
    → [页图绝对路径…]
    """
    import tempfile
    cache_dir = cache_dir or os.path.join(HERE, '.cache_pages')
    sig = hashlib.md5(f'{src}|{os.path.getmtime(src)}'.encode()).hexdigest()[:16]
    outdir = os.path.join(cache_dir, 'docx_' + sig)
    pdf = os.path.join(outdir, 'doc.pdf')
    if not os.path.exists(pdf):
        os.makedirs(outdir, exist_ok=True)
        work = tempfile.mkdtemp(prefix='zjdocx_')          # 纯 ASCII，转完即删
        try:
            tmp = os.path.join(work, 'src.docx')
            shutil.copy2(src, tmp)
            subprocess.run(['soffice', '--headless',
                            '-env:UserInstallation=file://' + os.path.join(work, 'profile'),
                            '--convert-to', 'pdf', '--outdir', work, tmp],
                           capture_output=True, timeout=300)
            made = os.path.join(work, 'src.pdf')
            if not os.path.exists(made):
                return []
            shutil.move(made, pdf)
        except Exception:
            return []
        finally:
            shutil.rmtree(work, ignore_errors=True)
    return render_pdf_pages(pdf, 1, 0, cache_dir)


def fallback_pdf(pgs):
    """通用下载兜底：从题目 pg 的页图路径推出「整本 PDF + 页范围」。

    页图目录名编码了册次（原书页/ykl/1s → 一课一练一年级上普通），
    整本 PDF 在 源题文件/<教材>/ 下按册名匹配。适用于 locate() 没覆盖的教材/册。
    → (整本pdf, 起页, 止页) 或 None
    """
    nums, rk = [], None
    for rel in pgs:
        m = re.search(r'原书页/(\w+)/([^/]+)/p(\d+)\.', rel)
        if m:
            rk = (m.group(1), m.group(2)); nums.append(int(m.group(3)))
    if not nums or not rk:
        return None
    lo, hi = min(nums), max(nums)
    kind, sub = rk
    if kind == 'ykl':
        # 1s → 1年级上普通；源文件名形如「数学一课一练上海-1年级上-普通版-2024秋-…pdf」
        m = re.match(r'(\d)([sx])(z?)$', sub)
        if not m:
            return None
        gr, term, enh = m.group(1), ('上' if m.group(2) == 's' else '下'), bool(m.group(3))
        want = f'{gr}年级{term}'
        for p in glob.glob(os.path.join(SRCDIR, '一课一练', '*.pdf')):
            b = os.path.basename(p)
            if want in b and (('增强' in b) == enh):
                return (p, lo, hi)
    return None


# 现渲页图的统一宽度（px）。与 成果/胡小群讲义/pages/ 里已渲好的 1241px 对齐，
# 这样预览里新渲的和现成的看起来一样大。
PAGE_PX = 1240


def render_pdf_pages(src, lo=1, hi=0, cache_dir=None):
    """把 PDF 渲成页图（poppler 的 pdftoppm，0.15 秒/页，**不是 LibreOffice**）。

    给那些没有预渲染页图的源用（如胡小群课后习题），让**预览和下载始终同源**：
    下载给哪几页、预览就显示哪几页。结果按「源路径+页范围+mtime+出图设置」缓存。

    ⚠ 按**宽度**出图，不按 dpi。源 PDF 的纸张大小并不都是 A4：
      一课一练有 8 份全册答案是 2000×2835 pt（≈28×39 英寸），固定 `-r 110` 渲出来
      是 3056×4344、**单张 7.8 MB**，8 份就吃掉 0.59 GB，浏览器里也拖得动不了。
      `-scale-to-x` 让任何纸张都出同一个宽度（1240px，与成果里已渲的讲义页图 1241px 对齐）。
    → [页图绝对路径…]
    """
    cache_dir = cache_dir or os.path.join(HERE, '.cache_pages')
    os.makedirs(cache_dir, exist_ok=True)
    import hashlib
    # 出图设置进签名 —— 改了宽度，旧缓存自然失效重渲，不会拿旧图糊弄
    sig = hashlib.md5(f'{src}|{lo}|{hi}|{os.path.getmtime(src)}|w{PAGE_PX}'
                      .encode()).hexdigest()[:16]
    outdir = os.path.join(cache_dir, sig)
    got = sorted(glob.glob(os.path.join(outdir, '*.png')))
    if got:
        return got
    os.makedirs(outdir, exist_ok=True)
    cmd = ['pdftoppm', '-png', '-scale-to-x', str(PAGE_PX), '-scale-to-y', '-1']
    if lo:
        cmd += ['-f', str(lo)]
    if hi:
        cmd += ['-l', str(hi)]
    cmd += [src, os.path.join(outdir, 'p')]
    try:
        subprocess.run(cmd, capture_output=True, timeout=300)
    except Exception:
        return []
    return sorted(glob.glob(os.path.join(outdir, '*.png')))


def cut_pdf(src, lo, hi, out):
    """用 qpdf 按页范围切 PDF（0.05 秒级，系统自带，无第三方依赖）。"""
    r = subprocess.run(['qpdf', src, '--pages', '.', f'{lo}-{hi}', '--', out],
                       capture_output=True, timeout=120)
    return os.path.exists(out)


if __name__ == '__main__':
    import sys
    print(locate(*sys.argv[1:4]))
