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:
- 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ượngBTGroup, với lớpTextStateManagerđảm nhiệm việc theo dõi sự thay đổi phông và hệ tọa độ khi gặp lệnhTf,Tm, hoặcTd. - 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ácBTGroupnằm gần nhau theo trụcy. Đ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. - 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 độxthành chỉ số cột ký tự. Tham sốpreserve_vertical_gapscho 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 độ
txtrung 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 độ
txbằ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ùngextract_tables()(nếu có) hoặc tích hợp thư viện chuyên biệt nhưcamelothoặctabula-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ìpypdfkhông hỗ trợ nhận diện ảnh.