Files
Z User e45b68bb8b feat: IMAP mail sync with thread-to-contact matching (PRD §6.1)
Real IMAP connection via imapflow library:
- Connects to IMAP server (IMAPFlow, TLS on port 993)
- Fetches recent messages (configurable limit, default 20)
- Extracts: from, fromEmail, subject, date, snippet (200 chars)
- Thread-to-contact matching (PRD §6.1):
  1. Match sender email against persons table
  2. If no person match, extract domain and match against company.website
  3. Returns matchedCompany + matchedPerson for each email
- Audit-logged (action: crm.mailsync:imap)
- Zod validated (host, port, user, password, folder, limit)
- 502 on IMAP connection failure with detail
- 400 on validation failure

Tested: invalid creds → 502 'IMAP connection failed', missing host → 400
2026-10-27 22:50:50 +00:00

1175 lines
42 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
PDF Quality Assurance Checker
=============================
Automatically detects common typesetting issues in PDFs.
Usage: python3 pdf_qa.py <pdf_path>
Checks:
1. Page size consistency across all pages
2. Blank page detection
3. CJK punctuation placement (line-start/end forbidden punctuation)
4. Color analysis (informational only — counts and lists colors)
5. Font embedding check (warns on non-embedded fonts)
6. PDF metadata check (title/author/creator)
7. Content overflow detection (text exceeding page boundaries)
8. Content fill ratio per page (multi-page docs, warns if < 40%)
9. Cover/poster full-bleed check (background extends to page edges)
10. Margin symmetry check (left/right text margins)
11. Table centering check (if detected)
12. Formula overflow check (optional)
13. TOC placeholder detection (empty/unfilled table of contents)
"""
import sys
import os
import re
import json
from collections import Counter
try:
import pymupdf # PyMuPDF
except ImportError:
import fitz as pymupdf
# ============================================================
# Config
# ============================================================
# CJK punctuation forbidden at line start
LINE_START_FORBIDDEN = set(
"。、,;:!?)】〛〉」』"
"\u201c\u201d" # "" curly double quotes
"\u2026" # … ellipsis
"\u2014" # — em dash
"\uff5e" # ~ fullwidth tilde
"\u00b7" # · middle dot
)
# CJK punctuation forbidden at line end
LINE_END_FORBIDDEN = set(
"(【《〈「"
"\u2018\u2019" # '' curly single quotes
"\u201c" # " left curly double quote
)
# Minimum fill ratio for last page (DISABLED — caused false positives)
# LAST_PAGE_MIN_FILL = 0.40
# Maximum allowed color count — REMOVED (color count is now info-only)
# MAX_COLORS = 8
# ============================================================
# Checks
# ============================================================
class QAResult:
def __init__(self):
self.issues = [] # (severity, category, message)
self.passes = [] # passed checks
self.info = [] # informational
def error(self, cat, msg):
self.issues.append(('ERROR', cat, msg))
def fail(self, cat, msg):
"""Alias for error — used for hard failures."""
self.issues.append(('ERROR', cat, msg))
def warn(self, cat, msg):
self.issues.append(('WARN', cat, msg))
def ok(self, msg):
self.passes.append(msg)
def add_info(self, msg):
self.info.append(msg)
def check_last_page_fill(doc, result):
"""Check content fill ratio of the last page"""
if len(doc) < 2:
result.ok("Single-page document, no last-page blank check needed")
return
last_page = doc[-1]
page_rect = last_page.rect
page_area = page_rect.width * page_rect.height
# Get bounding boxes of all content on last page
blocks = last_page.get_text("blocks")
if not blocks:
result.error("Last page blank", f"Page {len(doc)} (last page) has no content at all!")
return
# Calculate max y-coordinate covered by content
max_y = 0
min_y = page_rect.height
for b in blocks:
if b[4].strip(): # Has text content
min_y = min(min_y, b[1])
max_y = max(max_y, b[3])
if max_y == 0:
result.error("Last page blank", f"Page {len(doc)} (last page) has no valid text content")
return
content_height = max_y - min_y
fill_ratio = content_height / page_rect.height
result.add_info(f"Last page fill ratio: {fill_ratio:.0%} (content height {content_height:.0f}px / page height {page_rect.height:.0f}px)")
if fill_ratio < 0.25:
result.error("Last page blank", f"Last page fill ratio only {fill_ratio:.0%}, mostly blank! Consider compressing preceding page spacing or trimming content")
elif fill_ratio < LAST_PAGE_MIN_FILL:
result.warn("Last page blank", f"Last page fill ratio {fill_ratio:.0%}, somewhat sparse — optimization recommended")
else:
result.ok(f"Last page fill ratio {fill_ratio:.0%} ✓")
def check_punctuation(doc, result):
"""Check CJK punctuation placement rules"""
violations = []
for page_num in range(len(doc)):
page = doc[page_num]
# Extract text by line
text_dict = page.get_text("dict")
for block in text_dict.get("blocks", []):
if block.get("type") != 0: # Only check text blocks
continue
for line in block.get("lines", []):
line_text = ""
for span in line.get("spans", []):
line_text += span.get("text", "")
line_text = line_text.strip()
if not line_text:
continue
# Check line start
first_char = line_text[0]
if first_char in LINE_START_FORBIDDEN:
violations.append((page_num + 1, f"Forbidden line-start punctuation '{first_char}': ...{line_text[:30]}"))
# Check line end
last_char = line_text[-1] if len(line_text) > 0 else ''
if last_char in LINE_END_FORBIDDEN:
violations.append((page_num + 1, f"Forbidden line-end punctuation '{last_char}': {line_text[-30:]}..."))
if violations:
# Show at most 10
shown = violations[:10]
for page_num, desc in shown:
result.warn("Punctuation rules", f"Page {page_num} - {desc}")
if len(violations) > 10:
result.warn("Punctuation rules", f"...{len(violations) - 10} more violations")
else:
result.ok("Punctuation placement check passed ✓")
def check_blank_pages(doc, result):
"""Check for completely blank pages"""
blank_pages = []
for i in range(len(doc)):
page = doc[i]
text = page.get_text().strip()
# Also check for images
images = page.get_images()
drawings = page.get_drawings()
if not text and not images and not drawings:
blank_pages.append(i + 1)
if blank_pages:
result.error("Blank pages", f"Found blank pages: {blank_pages}")
else:
result.ok("No blank pages ✓")
def check_colors(doc, result):
"""Analyze colors used in the document (informational only, no pass/fail)"""
colors = set()
for page_num in range(len(doc)):
page = doc[page_num]
text_dict = page.get_text("dict")
for block in text_dict.get("blocks", []):
if block.get("type") != 0:
continue
for line in block.get("lines", []):
for span in line.get("spans", []):
color = span.get("color", 0)
if color != 0: # Exclude pure black
r = (color >> 16) & 0xFF
g = (color >> 8) & 0xFF
b = color & 0xFF
hex_color = f"#{r:02x}{g:02x}{b:02x}"
colors.add(hex_color)
# Check drawing colors
drawings = page.get_drawings()
for d in drawings:
if d.get("color"):
c = d["color"]
if isinstance(c, (tuple, list)) and len(c) >= 3:
hex_color = f"#{int(c[0]*255):02x}{int(c[1]*255):02x}{int(c[2]*255):02x}"
colors.add(hex_color)
if d.get("fill"):
c = d["fill"]
if isinstance(c, (tuple, list)) and len(c) >= 3:
hex_color = f"#{int(c[0]*255):02x}{int(c[1]*255):02x}{int(c[2]*255):02x}"
colors.add(hex_color)
# Filter out near-black/white/gray colors
distinct_colors = []
for c in colors:
r = int(c[1:3], 16)
g = int(c[3:5], 16)
b = int(c[5:7], 16)
max_diff = max(abs(r-g), abs(g-b), abs(r-b))
if max_diff > 20:
distinct_colors.append(c)
result.add_info(f"Total text colors: {len(colors)} (chromatic: {len(distinct_colors)})")
if distinct_colors:
result.add_info(f"Chromatic colors: {', '.join(sorted(distinct_colors)[:10])}")
def check_page_size_consistency(doc, result):
"""Check whether all page sizes are consistent.
Distinguishes between real size mismatches (ERROR) and mere
portrait/landscape mixed orientation (OK). When a size mismatch is
detected, the error message includes remediation guidance so the
generating agent can self-correct.
"""
if len(doc) < 2:
result.ok("Single-page document, size consistent ✓")
return
sizes = set()
normalized = set() # orientation-agnostic: always (short, long)
for i in range(len(doc)):
page = doc[i]
w = round(page.rect.width, 1)
h = round(page.rect.height, 1)
sizes.add((w, h))
normalized.add(tuple(sorted((w, h))))
if len(normalized) > 1:
# True size mismatch (not just orientation) — this is a hard error.
# Build remediation hint: identify the majority size as "expected"
# and flag outliers, with concrete --width guidance for covers.
page_sizes = []
for i in range(len(doc)):
page = doc[i]
w = round(page.rect.width, 1)
h = round(page.rect.height, 1)
page_sizes.append((w, h))
from collections import Counter
size_counts = Counter(page_sizes)
majority_size = size_counts.most_common(1)[0][0]
outlier_pages = [i + 1 for i, s in enumerate(page_sizes) if s != majority_size]
outlier_size = page_sizes[outlier_pages[0] - 1]
# Compute the correct --width value for html2poster.js
# PDF pt → CSS px conversion: px = pt × 96 / 72
expected_w_px = round(majority_size[0] * 96 / 72)
expected_h_px = round(majority_size[1] * 96 / 72)
msg = (
f"Inconsistent page sizes: {sizes}. "
f"Page(s) {outlier_pages} are {outlier_size[0]}×{outlier_size[1]}pt "
f"but the majority ({len(doc) - len(outlier_pages)} pages) are "
f"{majority_size[0]}×{majority_size[1]}pt. "
f"FIX: Regenerate the cover/outlier page HTML with "
f"width: {expected_w_px}px; height: {expected_h_px}px "
f"(A4 at 96dpi) and render with "
f"html2poster.js --width {expected_w_px}px. "
f"The current outlier appears to use {outlier_size[0]}pt values "
f"as px, causing a 25% size reduction (72dpi/96dpi mismatch)."
)
result.error("Page size", msg)
elif len(sizes) > 1:
# Same dimensions, just portrait vs landscape — this is fine
result.ok(f"Page size consistent (mixed orientation) ✓ — {sizes}")
else:
size = list(sizes)[0]
# Convert to mm
w_mm = size[0] * 25.4 / 72
h_mm = size[1] * 25.4 / 72
result.add_info(f"Page size: {w_mm:.0f}mm × {h_mm:.0f}mm ({len(doc)} pages)")
result.ok("Page size consistent ✓")
def check_text_overflow(doc, result):
"""Check whether text overflows page boundaries"""
overflow_pages = []
for i in range(len(doc)):
page = doc[i]
rect = page.rect
blocks = page.get_text("blocks")
for b in blocks:
# b = (x0, y0, x1, y1, text, block_no, block_type)
if b[2] > rect.width + 2 or b[3] > rect.height + 2: # 2px tolerance
overflow_pages.append(i + 1)
break
if b[0] < -2 or b[1] < -2:
overflow_pages.append(i + 1)
break
if overflow_pages:
result.warn("Content overflow", f"Pages {overflow_pages} may have content exceeding page boundaries")
else:
result.ok("No content overflow ✓")
def check_content_fill_ratio(doc, result):
"""Check content fill ratio per page — warns when content is crammed at top leaving large void below.
Rules:
- Skip single-page documents (may be intentional design)
- Skip page 1 (usually cover with intentional whitespace)
- Middle pages: warn if fill ratio < 40%
- Last page: warn if fill ratio < 25% (naturally has less content)
"""
if len(doc) < 2:
result.ok("Single-page document, skipping content fill ratio check ✓")
return
low_fill_pages = []
for i in range(len(doc)):
page = doc[i]
page_rect = page.rect
page_height = page_rect.height
# Skip page 1 (cover)
if i == 0:
continue
blocks = page.get_text("blocks")
images = page.get_images()
drawings = page.get_drawings()
if not blocks and not images and not drawings:
continue # Blank page check handles this
# Calculate content bbox
max_y = 0
for b in blocks:
if b[4].strip():
max_y = max(max_y, b[3])
# Include images in bbox
for img in images:
try:
img_rects = page.get_image_rects(img[0])
for r in img_rects:
max_y = max(max_y, r.y1)
except Exception:
pass
if max_y == 0:
continue
fill_ratio = max_y / page_height
is_last = (i == len(doc) - 1)
threshold = 0.25 if is_last else 0.40
if fill_ratio < threshold:
low_fill_pages.append((i + 1, fill_ratio, threshold))
if low_fill_pages:
for pg, ratio, thresh in low_fill_pages:
result.warn(
"Content fill ratio",
f"Page {pg} content only fills {ratio:.0%} of page height "
f"(threshold: {thresh:.0%}). Content may be crammed at the top "
f"with a large blank area below."
)
else:
result.ok("Content fill ratio adequate on all pages ✓")
def check_cover_bleed(doc, result, poster=False):
"""Check if the cover page (page 1) fills the entire page area (full-bleed).
A properly designed cover should have background color/graphics extending
to the page edges. If the content bbox has significant margins on all sides,
the cover likely wasn't rendered full-bleed (e.g. ReportLab with default margins).
For poster mode: checks ALL pages (not just the cover) since every page of a
seamlessly-paginated poster should have consistent background fill.
Strategy: combine bounding boxes of drawings (rects, paths), images, and colored
backgrounds. If the union bbox leaves > 5% margin on any side, warn.
"""
if not poster and len(doc) < 2:
# Single page doc (non-poster) — not necessarily a cover scenario
return
pages_to_check = range(len(doc)) if poster else [0]
for page_idx in pages_to_check:
page = doc[page_idx]
page_rect = page.rect
pw, ph = page_rect.width, page_rect.height
# Collect all content bounding boxes
min_x, min_y = pw, ph
max_x, max_y = 0.0, 0.0
has_content = False
# 1. Drawings (vector paths, rectangles — typical for colored backgrounds)
for d in page.get_drawings():
r = d.get("rect")
if r:
min_x = min(min_x, r.x0)
min_y = min(min_y, r.y0)
max_x = max(max_x, r.x1)
max_y = max(max_y, r.y1)
has_content = True
# 2. Images
for img in page.get_images():
try:
for r in page.get_image_rects(img[0]):
min_x = min(min_x, r.x0)
min_y = min(min_y, r.y0)
max_x = max(max_x, r.x1)
max_y = max(max_y, r.y1)
has_content = True
except Exception:
pass
page_label = f"Page {page_idx + 1}" if poster else "Cover page (p1)"
if not has_content:
blocks = page.get_text("blocks")
if blocks:
result.warn(
f"{page_label} not full-bleed",
f"{page_label} has no background graphics (no filled rectangles or images). "
"A proper cover/poster page should have a full-page background color or image "
"extending to all edges."
)
continue
# Calculate margin ratios (how far content is from page edges)
margin_left = max(0, min_x) / pw
margin_top = max(0, min_y) / ph
margin_right = max(0, pw - max_x) / pw
margin_bottom = max(0, ph - max_y) / ph
threshold = 0.05
margins_ok = (margin_left <= threshold and margin_top <= threshold and
margin_right <= threshold and margin_bottom <= threshold)
if margins_ok:
result.ok(f"{page_label} content extends to page edges (full-bleed) ✓")
else:
sides = []
if margin_left > threshold:
sides.append(f"left {margin_left:.0%}")
if margin_top > threshold:
sides.append(f"top {margin_top:.0%}")
if margin_right > threshold:
sides.append(f"right {margin_right:.0%}")
if margin_bottom > threshold:
sides.append(f"bottom {margin_bottom:.0%}")
result.warn(
f"{page_label} not full-bleed",
f"{page_label} has visible margins: {', '.join(sides)}. "
f"Background/graphics should extend to page edges."
)
def check_margin_symmetry(doc, result, skip_cover=False):
"""Check left/right margin symmetry using text block bounds."""
warn_pages = []
for page_num in range(len(doc)):
if skip_cover and page_num == 0:
continue
page = doc[page_num]
blocks = page.get_text("blocks")
text_blocks = [b for b in blocks if b[4].strip()]
if len(text_blocks) < 3:
continue # Skip decorative/cover-like pages
left_margin = min(b[0] for b in text_blocks)
right_margin = page.rect.width - max(b[2] for b in text_blocks)
diff = abs(left_margin - right_margin)
if diff > page.rect.width * 0.05:
warn_pages.append((page_num + 1, left_margin, right_margin, diff))
if warn_pages:
for pg, left, right, diff in warn_pages:
result.warn(
"Margin symmetry",
f"Page {pg} left/right margins differ by {diff:.0f}pt "
f"(L {left:.0f}pt, R {right:.0f}pt)"
)
else:
result.ok("Left/right margins appear symmetric \u2713")
def check_table_centering(doc, result):
"""Check if detected table regions are centered."""
def _bbox_intersects(a, b, tol=6):
return not (a[2] < b[0] - tol or a[0] > b[2] + tol or
a[3] < b[1] - tol or a[1] > b[3] + tol)
def _rect_tuple(r):
if hasattr(r, "x0"):
return (r.x0, r.y0, r.x1, r.y1)
return (r[0], r[1], r[2], r[3])
any_tables = False
for page_num in range(len(doc)):
page = doc[page_num]
drawings = page.get_drawings()
segments = []
for d in drawings:
for item in d.get("items", []):
if not item:
continue
op = item[0]
if op == "l" and len(item) >= 3:
p0, p1 = item[1], item[2]
segments.append((p0[0], p0[1], p1[0], p1[1]))
elif op == "re" and len(item) >= 2:
x0, y0, x1, y1 = _rect_tuple(item[1])
segments.extend([
(x0, y0, x1, y0),
(x0, y1, x1, y1),
(x0, y0, x0, y1),
(x1, y0, x1, y1),
])
if not segments:
continue
cluster_list = []
for x0, y0, x1, y1 in segments:
min_x, max_x = min(x0, x1), max(x0, x1)
min_y, max_y = min(y0, y1), max(y0, y1)
bbox = (min_x, min_y, max_x, max_y)
is_h = abs(y0 - y1) < 1 and (max_x - min_x) > 20
is_v = abs(x0 - x1) < 1 and (max_y - min_y) > 20
if not is_h and not is_v:
continue
placed = False
for cl in cluster_list:
if _bbox_intersects(bbox, cl["bbox"]):
cl["segments"].append((x0, y0, x1, y1, is_h, is_v))
cl["bbox"] = (
min(cl["bbox"][0], bbox[0]),
min(cl["bbox"][1], bbox[1]),
max(cl["bbox"][2], bbox[2]),
max(cl["bbox"][3], bbox[3]),
)
if is_h:
cl["h"] += 1
if is_v:
cl["v"] += 1
placed = True
break
if not placed:
cluster_list.append({
"bbox": bbox,
"segments": [(x0, y0, x1, y1, is_h, is_v)],
"h": 1 if is_h else 0,
"v": 1 if is_v else 0,
})
for cl in cluster_list:
if cl["h"] < 2 or cl["v"] < 2:
continue
any_tables = True
bbox = cl["bbox"]
page_width = page.rect.width
left_margin = bbox[0]
right_margin = page_width - bbox[2]
if abs(left_margin - right_margin) > page_width * 0.05:
result.warn(
"Table centering",
f"Page {page_num + 1}: Table not centered "
f"(L {left_margin:.0f}pt, R {right_margin:.0f}pt)"
)
if any_tables:
result.ok("Table centering check complete \u2713")
def check_font_embedding(doc, result):
"""Check font embedding status using PyMuPDF font list."""
fonts_used = set()
non_embedded = set()
for page_num in range(len(doc)):
page = doc[page_num]
for font in page.get_fonts():
basefont = font[3] if len(font) > 3 else "unknown"
ext = font[1] if len(font) > 1 else ""
fonts_used.add(basefont)
if not ext:
non_embedded.add(basefont)
if fonts_used:
result.add_info(f"Fonts used: {', '.join(sorted(fonts_used))}")
else:
result.add_info("Fonts used: (none detected)")
if non_embedded:
for basefont in sorted(non_embedded):
result.warn(
"Font embedding",
f"Font {basefont} is not embedded. May display differently on other systems."
)
else:
result.ok("All fonts are embedded \u2713")
def check_helvetica_in_cjk(doc, result):
"""Detect Helvetica rendering visible text in documents containing CJK text.
Helvetica is a Latin-only built-in PDF font. When it appears rendering
actual text content in a CJK document, it almost always means a raw string
was passed to a ReportLab Table or flowable without wrapping it in
Paragraph() with a CJK font. The CJK characters rendered via Helvetica
become garbled (fall back to ZapfDingbats symbols).
We only check Helvetica (not ZapfDingbats) because ZapfDingbats is
legitimately used for bullet symbols in list items.
We check actual rendered text spans (not just font presence in font list)
because ReportLab internally registers Helvetica on every page even when
only CJK fonts are used in visible content.
"""
has_cjk = False
helvetica_pages = []
for page_num in range(len(doc)):
page = doc[page_num]
text = page.get_text("text") or ""
# Check if document contains CJK characters
if not has_cjk:
for ch in text:
if '\u4e00' <= ch <= '\u9fff' or '\u3400' <= ch <= '\u4dbf':
has_cjk = True
break
# Check if Helvetica is actually used to render visible text on this page
blocks = page.get_text("dict", sort=True).get("blocks", [])
found_on_page = False
for block in blocks:
if found_on_page:
break
for line in block.get("lines", []):
if found_on_page:
break
for span in line.get("spans", []):
font = span.get("font", "")
txt = span.get("text", "").strip()
if "Helvetica" in font and len(txt) > 0:
helvetica_pages.append(page_num + 1)
found_on_page = True
break
if has_cjk and helvetica_pages:
pages_str = ', '.join(str(p) for p in helvetica_pages[:5])
if len(helvetica_pages) > 5:
pages_str += f' ...and {len(helvetica_pages) - 5} more'
result.warn(
"Helvetica in CJK document",
f"Helvetica font detected rendering text on page(s) {pages_str} in a CJK document. "
f"This usually means a raw string was passed to a ReportLab Table or flowable "
f"without wrapping in Paragraph(text, style) with a CJK-capable font (e.g. Liberation Sans or Noto Sans SC). "
f"CJK characters rendered via Helvetica will appear as garbled symbols."
)
def check_metadata(doc, result):
"""Check PDF metadata presence for title, author, creator."""
meta = doc.metadata or {}
def _missing(v):
if v is None:
return True
if not str(v).strip():
return True
return False
title = meta.get("title")
author = meta.get("author")
creator = meta.get("creator")
if _missing(title) or str(title).strip().lower() in ("untitled", "(anonymous)"):
result.warn("Metadata", "Missing/invalid title metadata")
else:
result.ok("Title metadata present \u2713")
if _missing(author):
result.warn("Metadata", "Missing author metadata")
else:
result.ok("Author metadata present \u2713")
if _missing(creator):
result.warn("Metadata", "Missing creator metadata")
else:
result.ok("Creator metadata present \u2713")
def check_toc_without_cover(doc, result):
"""Detect TOC on page 1 without a preceding cover page.
If the first page contains Table of Contents / 目录, it means the document
has a TOC but no cover page. This is a structural issue — documents with
TOC should have: Cover (p1) → TOC (p2) → Content (p3+).
"""
if len(doc) < 2:
# Single-page docs don't need TOC/cover checks
return
page1 = doc[0]
text = page1.get_text("text", sort=True).strip()
# Normalize for matching
text_lower = text.lower()
first_300 = text_lower[:300]
toc_keywords = [
"table of contents", "contents",
"目录", "目 录",
]
has_toc = any(kw in first_300 for kw in toc_keywords)
if has_toc:
result.warn(
"TOC without cover",
"Page 1 appears to be a Table of Contents with no preceding cover page. "
"Documents with TOC should have: Cover (p1) → TOC (p2) → Content (p3+)."
)
def check_toc_placeholder(doc, result):
"""Detect TOC pages that are empty placeholders instead of real table of contents.
ReportLab's TableOfContents requires TocDocTemplate + multiBuild() to work.
If the code uses SimpleDocTemplate + build(), or forgets to set bookmark
attributes on headings and call notify('TOCEntry', ...), the TOC page will
remain a placeholder with no actual entries.
Detection strategy:
1. Find pages that contain TOC title keywords ("目录", "Table of Contents")
2. Check for explicit placeholder text ("Placeholder for table of contents")
3. Check if the TOC page has suspiciously little content (title only, no entries)
4. Check if entries exist but have no page numbers (another multiBuild failure mode)
"""
total_pages = len(doc)
if total_pages < 3:
return # Too short to have a meaningful TOC
toc_keywords = [
"table of contents", "contents",
"目录", "目 录", "目 录",
]
placeholder_phrases = [
"placeholder for table of contents",
"placeholder",
]
for page_idx in range(min(5, total_pages)): # TOC is usually in first 5 pages
page = doc[page_idx]
page_text = page.get_text("text", sort=True)
text_lower = page_text.lower().strip()
first_500 = text_lower[:500]
# Is this a TOC page?
is_toc_page = any(kw in first_500 for kw in toc_keywords)
if not is_toc_page:
continue
# Check 1: Explicit placeholder text
if any(ph in text_lower for ph in placeholder_phrases):
result.error(
"TOC placeholder",
f"Page {page_idx + 1} contains a TOC placeholder instead of actual table of contents. "
f"This happens when using SimpleDocTemplate.build() instead of TocDocTemplate.multiBuild(). "
f"ReportLab's TableOfContents requires: (1) subclass SimpleDocTemplate with afterFlowable() "
f"that calls self.notify('TOCEntry', ...), (2) set bookmark_name/bookmark_level/bookmark_text/"
f"bookmark_key attributes on heading Paragraphs, (3) call doc.multiBuild(story) instead of "
f"doc.build(story)."
)
return
# Check 2: TOC page has very little content (just the title, no entries)
# A real TOC has multiple lines with chapter names and page numbers
lines = [l.strip() for l in page_text.split('\n') if l.strip()]
# Filter out the TOC title itself
non_title_lines = []
for l in lines:
l_lower = l.lower().strip()
# Skip the TOC title line
if any(kw in l_lower for kw in toc_keywords) and len(l) < 40:
continue
# Skip page numbers standing alone (from page footer)
if l.isdigit():
continue
non_title_lines.append(l)
if len(non_title_lines) == 0:
result.error(
"TOC placeholder",
f"Page {page_idx + 1} has a TOC title but no table of contents entries. "
f"The TOC is empty — likely caused by using SimpleDocTemplate.build() instead of "
f"TocDocTemplate.multiBuild(). ReportLab's TableOfContents needs multiBuild() to "
f"populate entries in a second pass."
)
return
# Check 3: TOC entries exist but none contain page numbers
# Real TOC entries typically end with a number or have dotted leaders + number
has_page_number = False
page_num_re = re.compile(r'\d+\s*$') # Line ending with a number
dotted_leader_re = re.compile(r'[\.·…]{3,}') # Dotted leaders
for l in non_title_lines:
if page_num_re.search(l) or dotted_leader_re.search(l):
has_page_number = True
break
if not has_page_number and len(non_title_lines) <= 3:
result.warn(
"TOC possibly incomplete",
f"Page {page_idx + 1} has a TOC title but only {len(non_title_lines)} line(s) "
f"without visible page numbers. The TOC may not have been properly generated. "
f"Verify that TocDocTemplate + multiBuild() is used and headings have bookmark attributes."
)
return
# If we get here, TOC looks populated with entries
# Check 4: TOC has entries but no clickable links (internal annotations)
# A real TOC generated by TocDocTemplate+multiBuild has <a> links that become
# PDF annotations (/Link /GoTo). Hand-coded TOC with plain Paragraph text
# will have zero link annotations on the TOC page.
try:
toc_links = page.get_links()
internal_links = [l for l in toc_links if l.get('kind') == 1] # kind=1 = internal GoTo
if len(non_title_lines) >= 3 and len(internal_links) == 0:
result.warn(
"TOC not clickable",
f"Page {page_idx + 1} has {len(non_title_lines)} TOC entries but ZERO clickable links. "
f"This usually means the TOC was hand-coded as plain text instead of using "
f"TableOfContents + TocDocTemplate + multiBuild(). The TOC entries are not navigable — "
f"readers cannot click to jump to sections."
)
return
except Exception:
pass # If link detection fails, don't block — the visual check already passed
result.ok(f"TOC on page {page_idx + 1} appears populated with entries ✓")
return
def check_body_page_numbers(doc, result):
"""Check that body pages (after TOC) have visible page numbers.
Logic:
1. Find TOC page(s) by scanning first 5 pages for TOC keywords
2. If no TOC found → skip (only documents with TOC need this check)
3. Determine body start = first page after last TOC page
4. For each body page, extract text from bottom 60pt strip
5. Look for Arabic or Roman numerals in that strip
6. >50% body pages missing page numbers → error
7. Some missing → warning with specific page list
"""
total_pages = len(doc)
if total_pages < 3:
return # Too short to have cover + TOC + body
# --- Step 1: Find TOC pages ---
toc_keywords = ["table of contents", "contents", "目录", "目 录", "目 录"]
toc_last_page = -1
check_limit = min(5, total_pages)
for i in range(check_limit):
page_text = doc[i].get_text("text", sort=True).lower()
first_500 = page_text[:500]
if any(kw in first_500 for kw in toc_keywords):
toc_last_page = i
if toc_last_page < 0:
return # No TOC found → skip check
# --- Step 2: Determine body range ---
body_start = toc_last_page + 1
if body_start >= total_pages:
return # TOC is the last page, no body to check
# --- Step 3: Check each body page for page numbers in footer area ---
page_height = doc[body_start].rect.height
footer_y_threshold = page_height - 60 # bottom 60pt
# Pattern: standalone Arabic number (1-9999) or Roman numeral (i-xxx)
arabic_re = re.compile(r'\b(\d{1,4})\b')
roman_re = re.compile(
r'\b(i{1,3}|iv|vi{0,3}|ix|xi{0,3}|xiv|xvi{0,3}|xix|'
r'xx{0,3}|xxiv|xxvi{0,3}|xxix|xxx)\b',
re.IGNORECASE
)
missing_pages = [] # 1-indexed page numbers with no footer number
body_page_count = 0
for page_idx in range(body_start, total_pages):
body_page_count += 1
page = doc[page_idx]
page_h = page.rect.height
footer_y = page_h - 60
# Extract text blocks and filter to footer region
blocks = page.get_text("blocks")
footer_texts = []
for b in blocks:
# b = (x0, y0, x1, y1, text, block_no, block_type)
if len(b) >= 5 and b[1] >= footer_y: # y0 >= threshold → in footer
text = b[4].strip() if isinstance(b[4], str) else ""
if text:
footer_texts.append(text)
footer_combined = " ".join(footer_texts)
# Check for page number
has_number = False
if arabic_re.search(footer_combined):
has_number = True
elif roman_re.search(footer_combined):
has_number = True
if not has_number:
missing_pages.append(page_idx + 1) # 1-indexed
if not missing_pages:
return # All good
missing_ratio = len(missing_pages) / body_page_count if body_page_count > 0 else 0
# Format page list for message (show up to 10)
if len(missing_pages) <= 10:
page_list = ", ".join(str(p) for p in missing_pages)
else:
page_list = ", ".join(str(p) for p in missing_pages[:10]) + f" ... ({len(missing_pages)} total)"
if missing_ratio > 0.5:
result.fail(
"Body page numbers missing",
f"{len(missing_pages)}/{body_page_count} body pages have no visible page number "
f"in footer area (pages: {page_list}). "
f"Check that PageTemplate onPage callback includes page number drawing."
)
else:
result.warn(
"Body page numbers partially missing",
f"{len(missing_pages)}/{body_page_count} body pages missing page numbers "
f"(pages: {page_list})."
)
def check_formula_overflow(doc, result):
"""Detect likely formula overflow past right content margin."""
math_re = re.compile(r"[=+\-*/<>\u2264\u2265\u2211\u222b\u221a\u03c0\u00b5\u221e\u2202\u2206\u2248\u2260\u00b1\u00d7\u00f7]")
for page_num in range(len(doc)):
page = doc[page_num]
blocks = page.get_text("blocks")
text_blocks = [b for b in blocks if b[4].strip()]
if len(text_blocks) < 3:
continue
right_edges = sorted(b[2] for b in text_blocks)
mid = len(right_edges) // 2
content_right = right_edges[mid] if right_edges else 0
for b in text_blocks:
x0, x1, text = b[0], b[2], b[4]
if x1 <= content_right + 10:
continue
is_single_line = "\n" not in text.strip()
is_wide = (x1 - x0) > page.rect.width * 0.5
has_math = bool(math_re.search(text))
if (is_single_line and is_wide) or has_math:
delta = x1 - content_right
result.warn(
"Formula overflow",
f"Page {page_num + 1}: Content extends {delta:.0f}pt beyond right content margin "
"(possible formula overflow)"
)
break
# ============================================================
# Main
# ============================================================
def run_qa(pdf_path, poster=False, skip_cover=False, check_tables=True, check_formulas=False):
result = QAResult()
if not os.path.exists(pdf_path):
result.error("File", f"File not found: {pdf_path}")
return result
doc = pymupdf.open(pdf_path)
result.add_info(f"File: {os.path.basename(pdf_path)}")
result.add_info(f"Size: {os.path.getsize(pdf_path) / 1024:.1f} KB")
if poster:
result.add_info("Mode: poster (creative)")
# Run all checks
check_metadata(doc, result)
check_page_size_consistency(doc, result)
check_blank_pages(doc, result)
check_punctuation(doc, result)
check_colors(doc, result)
check_font_embedding(doc, result)
check_helvetica_in_cjk(doc, result)
check_text_overflow(doc, result)
if not poster:
# Content fill ratio is not meaningful for posters — the last page
# of a seamlessly-paginated poster naturally has less content.
check_content_fill_ratio(doc, result)
check_cover_bleed(doc, result, poster=poster)
check_margin_symmetry(doc, result, skip_cover=skip_cover)
if check_tables:
check_table_centering(doc, result)
if check_formulas:
check_formula_overflow(doc, result)
if not poster:
check_toc_without_cover(doc, result)
check_toc_placeholder(doc, result)
check_body_page_numbers(doc, result)
doc.close()
return result
def format_report(result):
lines = []
lines.append("=" * 56)
lines.append(" PDF Quality Assurance Report")
lines.append("=" * 56)
# Info
if result.info:
lines.append("")
lines.append("ℹ️ Info:")
for msg in result.info:
lines.append(f" {msg}")
# Passes
if result.passes:
lines.append("")
lines.append(f"✅ Passed ({len(result.passes)}):")
for msg in result.passes:
lines.append(f" {msg}")
# Issues
errors = [(s, c, m) for s, c, m in result.issues if s == 'ERROR']
warns = [(s, c, m) for s, c, m in result.issues if s == 'WARN']
if errors:
lines.append("")
lines.append(f"❌ Errors ({len(errors)}):")
for _, cat, msg in errors:
lines.append(f" [{cat}] {msg}")
if warns:
lines.append("")
lines.append(f"⚠️ Warnings ({len(warns)}):")
for _, cat, msg in warns:
lines.append(f" [{cat}] {msg}")
# Summary
lines.append("")
lines.append("-" * 56)
total_issues = len(result.issues)
if total_issues == 0:
lines.append("🎉 PASS — All checks passed!")
elif errors:
lines.append(f"💀 FAIL — {len(errors)} error(s), {len(warns)} warning(s)")
else:
lines.append(f"⚠️ WARN — {len(warns)} warning(s), optimization recommended")
lines.append("-" * 56)
return "\n".join(lines)
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python3 pdf_qa.py <pdf_path>")
print(" python3 pdf_qa.py *.pdf (batch check)")
print("Options:")
print(" --poster Poster mode (creative)")
print(" --skip-cover Skip page 1 margin symmetry check")
print(" --no-tables Disable table centering check")
print(" --formulas Enable formula overflow check")
sys.exit(1)
import glob
files = []
poster = False
skip_cover = False
check_tables = True
check_formulas = False
args = sys.argv[1:]
if '--poster' in args:
poster = True
args.remove('--poster')
if '--skip-cover' in args:
skip_cover = True
args.remove('--skip-cover')
if '--no-tables' in args:
check_tables = False
args.remove('--no-tables')
if '--formulas' in args:
check_formulas = True
args.remove('--formulas')
for arg in args:
files.extend(glob.glob(arg))
if not files:
print(f"File not found: {args}")
sys.exit(1)
for pdf_path in files:
result = run_qa(
pdf_path,
poster=poster,
skip_cover=skip_cover,
check_tables=check_tables,
check_formulas=check_formulas
)
print(format_report(result))
if len(files) > 1:
print("\n")