Phân tích bố cục văn bản PDF nâng cao với pypdf: Phát hiện tiêu đề, đoạn văn và danh sách

Việc phân tích bố cục văn bản trong tài liệu PDF là bước then chốt để trích xuất thông tin có cấu trúc. Thư viện pypdf không chỉ hỗ trợ trích xuất ký tự thô mà còn cung cấp cơ chế quản lý trạng thái văn bản và tái tổ chức theo không gian — từ đó tạo nền tảng cho việc nhận diện tiêu đề phân cấp, ranh giới đoạn văn và định dạng danh sách.

Cơ chế phân tích bố cục nội tại

Quá trình xử lý bố cục trong pypdf được triển khai qua ba giai đoạn chính trong mô-đun _fixed_width_page.py:

  1. Ghi nhận trạng thái văn bản: Phân tích tuần tự các toán tử BT/ET để thu thập thông tin như kích thước phông chữ, ma trận biến đổi (bao gồm tọa độ x, y), độ đậm, và tên phông. Mỗi khối văn bản được đóng gói thành đối tượng BTGroup, với lớp TextStateManager đảm nhiệm việc theo dõi sự thay đổi phông và hệ tọa độ khi gặp lệnh Tf, Tm, hoặc Td.
  2. Nhóm theo vị trí dọc: Hàm y_coordinate_groups() sử dụng ngưỡng dựa trên chiều cao phông (font_height * 1.3) để gom các BTGroup nằm gần nhau theo trục y. Điều này khắc phục hiệu quả hiện tượng chồng lấn hoặc lệch hàng do PDF không lưu trữ thông tin dòng rõ ràng.
  3. Tái tạo bố cục cố định: Hàm fixed_width_page() ước lượng chiều rộng ký tự trung bình (avg_char_width) từ phông hiện hành, sau đó chuyển tọa độ x thành chỉ số cột ký tự. Tham số preserve_vertical_gaps cho phép giữ lại khoảng trắng giữa các khối văn bản — thiết yếu khi phân biệt đoạn văn và tiêu đề.

Nhận diện tiêu đề theo cấp bậc

Tiêu đề thường nổi bật nhờ đặc điểm thị giác: kích thước phông lớn hơn rõ rệt, độ đậm cao, và vị trí ở đầu trang hoặc đầu phần. Dưới đây là hàm phân tích tiêu đề tối ưu hóa cho tài liệu học thuật:

from pypdf import PdfReader
from typing import List, Dict, Any

def extract_section_headers(pdf_path: str, min_size: float = 14.0) -> List[Dict[str, Any]]:
    reader = PdfReader(pdf_path)
    headers = []
    
    for page_num, page in enumerate(reader.pages):
        # Lấy danh sách khối văn bản kèm metadata
        blocks = page.extract_text(
            layout=True,
            return_chars=False,
            strip_rotated=False
        )
        
        for blk in blocks:
            font_name = blk.get("font", "").lower()
            is_bold = "bold" in font_name or blk.get("font_weight", 0) >= 700
            y_coord = blk.get("transform", [0, 0, 0, 0, 0, 0])[5]
            
            if (
                blk.get("font_size", 0) >= min_size
                and len(blk.get("text", "")) < 60
                and is_bold
                and y_coord > page.mediabox.height * 0.1  # Loại bỏ header trang
            ):
                headers.append({
                    "page": page_num,
                    "text": blk["text"].strip(),
                    "level": estimate_heading_level(blk),
                    "y_position": round(y_coord, 1)
                })
    
    return sorted(headers, key=lambda x: (x["page"], x["y_position"]))

def estimate_heading_level(block: Dict) -> int:
    size = block.get("font_size", 0)
    if size >= 20: return 1
    elif size >= 16: return 2
    elif size >= 14: return 3
    else: return 4

Xác định ranh giới đoạn văn

Đoạn văn được xác định chủ yếu dựa vào khoảng cách dọc giữa các khối văn bản liền kề. Một đoạn thường có:

  • Khoảng cách giữa các dòng < 1.4 × font_height,
  • Khoảng cách giữa các đoạn > 2.2 × font_height,
  • Độ thụt đầu dòng dương (so với tọa độ tx trung bình của trang),
  • Tính nhất quán về căn lề: kiểm tra tỷ lệ (displaced_tx / page_width) để phân biệt căn trái (< 0.85), căn giữa (0.4–0.6), hoặc căn phải (> 0.85).

Hàm nhóm đoạn văn có thể được xây dựng dựa trên kết quả từ y_coordinate_groups(), sau đó áp dụng quy tắc hợp nhất nếu khoảng cách giữa hai nhóm nhỏ hơn ngưỡng đã tính.

Phát hiện danh sách có cấu trúc

Danh sách được nhận diện qua hai tín hiệu đồng thời: ký hiệu đánh dấu (số thứ tự, ký tự bullet) và mô hình thụt lề. Đoạn mã sau sử dụng biểu thức chính quy mở rộng và phân tích vị trí tương đối:

import re

def identify_lists(blocks: List[Dict]) -> List[Dict]:
    if not blocks:
        return []
    
    # Các mẫu phổ biến: số thứ tự, chữ cái, bullet Unicode
    patterns = [
        (r'^\s*(\d{1,3}\.)\s+', 'ordered'),
        (r'^\s*([a-z]\)|[A-Z]\))\s+', 'ordered'),
        (r'^\s*[\u2022\u25E6\u2023\u25CF]\s+', 'unordered'),
        (r'^\s*[-+*]\s+', 'unordered')
    ]
    
    lists = []
    current_list = None
    base_indent = blocks[0].get("tx", 0) if blocks else 0
    
    for blk in blocks:
        text = blk.get("text", "").strip()
        tx = blk.get("tx", 0)
        
        # Kiểm tra nếu là mục danh sách mới
        for pattern, list_type in patterns:
            if re.match(pattern, text):
                if current_list:
                    lists.append(current_list)
                current_list = {
                    "type": list_type,
                    "items": [text],
                    "indent": tx
                }
                break
        # Kiểm tra nếu là phần tiếp theo của danh sách hiện tại
        elif (current_list 
              and abs(tx - current_list["indent"]) < 25 
              and len(text) > 3):
            current_list["items"].append(text)
    
    if current_list:
        lists.append(current_list)
    
    return lists

Vận dụng thực tế và giới hạn

Trong tài liệu học thuật, nên kết hợp các kỹ thuật sau để tăng độ chính xác:

  • Sử dụng debug_path="layout_debug.json" để xuất dữ liệu trung gian và kiểm tra trực quan nhóm tọa độ,
  • Điều chỉnh ngưỡng kích thước tiêu đề theo từng loại tài liệu (ví dụ: báo cáo kỹ thuật thường dùng 13–15 pt, luận án dùng 16–18 pt),
  • Với tài liệu đa cột, phân tích phân bố tọa độ tx bằng histogram để xác định biên giới cột trước khi nhóm đoạn văn.

Các trường hợp đặc biệt cần xử lý riêng:

  • Bảng biểu: Không nên xử lý bằng extract_text(); thay vào đó dùng extract_tables() (nếu có) hoặc tích hợp thư viện chuyên biệt như camelot hoặc tabula-py.
  • Công thức toán: Thường được vẽ dưới dạng đường cong hoặc ký hiệu vector — cần phát hiện vùng chứa công thức qua phân tích nội dung stream và bỏ qua khi trích xuất văn bản.
  • Tài liệu quét: Yêu cầu OCR trước (ví dụ: pytesseract + pdf2image) vì pypdf không hỗ trợ nhận diện ảnh.

Thẻ: pypdf PDF-layout-analysis text-extraction document-structure python

Đăng vào ngày 13 tháng 6 lúc 23:06