Files
wehub-resource-sync 917eedffcf
Main / Python 3.11 - Docs (push) Waiting to run
Main / Python 3.11 - Build (push) Waiting to run
Main / Python 3.11 - Lint (push) Waiting to run
Main / Python 3.11 - Style (push) Waiting to run
Main / Python 3.11 - Test (push) Waiting to run
Main / GPU CI (push) Blocked by required conditions
Main / Release (push) Blocked by required conditions
Main / Build and Push Docker Images (push) Blocked by required conditions
chore: import upstream snapshot with attribution
2026-07-13 13:27:09 +08:00

2324 lines
95 KiB
Python

import argparse
import asyncio
import glob
import hashlib
import json
import logging
import os
import random
import re
import subprocess
import tempfile
import uuid
from collections import Counter, defaultdict
from typing import Dict, List
import pypdf
from anthropic import AsyncAnthropic
from bs4 import BeautifulSoup
from markdownify import SPACES, MarkdownConverter
from playwright.async_api import async_playwright
from syntok.segmenter import process
from tqdm import tqdm
from wordfreq import zipf_frequency
from olmocr.bench.tests import (
BaselineTest,
FootnoteTest,
FormatTest,
TableTest,
TestType,
TextOrderTest,
TextPresenceTest,
normalize_text,
parse_html_tables,
)
from olmocr.data.renderpdf import (
get_png_dimensions_from_base64,
render_pdf_to_base64png,
)
from olmocr.filter.filter import Language, PdfFilter
from olmocr.synth.claude_client import (
DEFAULT_MODEL_NAME,
call_claude,
claude_stream,
extract_code_block,
)
from olmocr.synth.cutoff_detection import (
RenderResult,
_detect_cutoff_on_page,
has_significant_cutoff,
)
# Global variables for tracking Claude API costs
total_input_tokens = 0
total_output_tokens = 0
def get_git_commit_hash():
"""Get the current git commit hash, if available."""
try:
result = subprocess.run(["git", "rev-parse", "HEAD"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True)
return result.stdout.strip()
except (subprocess.CalledProcessError, FileNotFoundError):
# Git not available or not a git repository
return None
# Unicode mappings for superscript characters
SUPERSCRIPT_MAP = {
"0": "⁰",
"1": "¹",
"2": "²",
"3": "³",
"4": "⁴",
"5": "⁵",
"6": "⁶",
"7": "⁷",
"8": "⁸",
"9": "⁹",
"+": "⁺",
"-": "⁻",
"=": "⁼",
"(": "⁽",
")": "⁾",
"n": "ⁿ",
"i": "ⁱ",
}
# Unicode mappings for subscript characters
SUBSCRIPT_MAP = {
"0": "₀",
"1": "₁",
"2": "₂",
"3": "₃",
"4": "₄",
"5": "₅",
"6": "₆",
"7": "₇",
"8": "₈",
"9": "₉",
"+": "₊",
"-": "₋",
"=": "₌",
"(": "₍",
")": "₎",
"a": "ₐ",
"e": "ₑ",
"o": "ₒ",
"x": "ₓ",
"h": "ₕ",
"k": "ₖ",
"l": "ₗ",
"m": "ₘ",
"n": "ₙ",
"p": "ₚ",
"s": "ₛ",
"t": "ₜ",
}
def convert_superscripts_subscripts(element):
"""
Convert HTML superscript and subscript tags to Unicode equivalents.
This function finds all <sup> and <sub> tags in the given element and
replaces them with their Unicode character equivalents. Characters not
in the mapping are left unchanged.
Args:
element: A BeautifulSoup element to process
Returns:
The element with sup/sub tags converted to Unicode
"""
if not element:
return element
# Process all superscript tags
for sup in element.find_all("sup"):
sup_text = sup.get_text()
unicode_text = "".join(SUPERSCRIPT_MAP.get(char, char) for char in sup_text)
sup.replace_with(unicode_text)
# Process all subscript tags
for sub in element.find_all("sub"):
sub_text = sub.get_text()
unicode_text = "".join(SUBSCRIPT_MAP.get(char, char) for char in sub_text)
sub.replace_with(unicode_text)
return element
def download_s3_pdf(path, local_path):
"""Download a PDF from S3 or copy from local path."""
os.makedirs(os.path.dirname(local_path), exist_ok=True)
# Check if it's a local path
if os.path.exists(path):
# It's a local file, just copy it
import shutil
try:
shutil.copy2(path, local_path)
return True
except Exception as e:
print(f"Failed to copy local file {path}: {e}")
return False
elif path.startswith("s3://"):
# It's an S3 path, download it
result = subprocess.run(["aws", "s3", "cp", path, local_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
return result.returncode == 0
else:
# Assume it's a relative local path that doesn't exist yet
print(f"Path not found and doesn't appear to be S3: {path}")
return False
def cleanup_headers_footers_soup(soup):
# Remove headers completely
for header in soup.find_all("header"):
header.decompose()
# For footers: remove direct text but keep footnote elements
for footer in soup.find_all("footer"):
# First, preserve all footnote elements (div, span, p with class="footnote")
footnote_elements = []
for tag_type in ["div", "span", "p"]:
footnote_elements.extend(footer.find_all(tag_type, class_="footnote"))
# Extract and temporarily store footnote elements
preserved_elements = []
for fn_element in footnote_elements:
# Extract the element from its current position
fn_element.extract()
preserved_elements.append(fn_element)
# Clear all content from the footer
footer.clear()
# Re-add only the footnote elements back to the footer
for fn_element in preserved_elements:
footer.append(fn_element)
# Remove any divs or spans with class "line-number"
for element in soup.find_all(["div", "span"], class_="line-number"):
element.extract()
# Remove any div or span watermarks
for element in soup.find_all(["div", "span"], class_="watermark"):
element.extract()
class PreserveTablesConverter(MarkdownConverter):
"""
Custom MarkdownConverter that preserves HTML tables unchanged
and preserves sup/sub tags as HTML
"""
def convert_table(self, el, text, parent_tags):
# Get the outer HTML of the table element
# BeautifulSoup's prettify or str() should give us the full HTML
from bs4 import BeautifulSoup
# Create a temporary soup with just this element to get its HTML
temp_soup = BeautifulSoup(str(el), "html.parser")
return str(temp_soup.table) if temp_soup.table else str(el)
def convert_sup(self, el, text, parent_tags):
# Always preserve sup tags as HTML
return f"<sup>{el.get_text()}</sup>"
def convert_sub(self, el, text, parent_tags):
# Always preserve sub tags as HTML
return f"<sub>{el.get_text()}</sub>"
def extract_html_metadata(html_content):
"""Extract metadata from HTML content for FrontMatter."""
soup = BeautifulSoup(html_content, "html.parser")
# Extract language from html tag
html_tag = soup.find("html")
language = "en" # default
if html_tag and html_tag.get("lang"):
language = str(html_tag.get("lang"))
# Convert pt-BR to pt for now
if len(language) == 5 and language[2] == "-":
language = language[:2]
# Calculate content statistics
body = soup.find("body")
if not body:
body = soup
# First, create a version without headers and footers for all calculations
main_content_soup = BeautifulSoup(str(body), "html.parser")
# Remove headers and footers from main content
for element in main_content_soup.find_all(["header", "footer"]):
element.decompose()
# Get text content length (excluding tables and images)
text_soup = BeautifulSoup(str(main_content_soup), "html.parser")
# Remove tables
for element in text_soup.find_all("table"):
element.decompose()
# Remove images (div.image)
for element in text_soup.find_all("div", class_="image"):
element.decompose()
text_content = text_soup.get_text().strip()
text_length = len(text_content)
# Count table content (from main content, excluding headers/footers)
tables = main_content_soup.find_all("table")
table_text_length = 0
for table in tables:
table_text_length += len(table.get_text().strip())
# Count images (div.image elements) (from main content, excluding headers/footers)
images = main_content_soup.find_all("div", class_="image")
# Rough estimate: each image takes up about 500 characters worth of "space"
image_content_estimate = len(images) * 500
# Calculate total content "length"
total_content_length = text_length + table_text_length + image_content_estimate
# Determine if mostly tables or images
is_table = False
is_diagram = False
if total_content_length > 0:
table_ratio = table_text_length / total_content_length
image_ratio = image_content_estimate / total_content_length
is_table = table_ratio > 0.5
is_diagram = image_ratio > 0.5
return {"primary_language": language, "is_rotation_valid": True, "rotation_correction": 0, "is_table": is_table, "is_diagram": is_diagram}
def html_to_markdown_with_frontmatter(html_content):
"""Convert HTML to markdown with FrontMatter metadata."""
# Extract metadata
metadata = extract_html_metadata(html_content)
# Parse HTML and extract only body content for markdown conversion
soup = BeautifulSoup(html_content, "html.parser")
body = soup.find("body")
# If no body tag, use the whole soup as fallback
if body:
# Create a new soup with just the body content
body_soup = BeautifulSoup(str(body), "html.parser")
else:
body_soup = soup
# First, remove all header and footer elements from the body
cleanup_headers_footers_soup(body_soup)
# Also remove divs with page-header or page-footer classes (in case they weren't converted to header/footer tags)
for div in body_soup.find_all("div", class_="page-header"):
div.decompose()
for div in body_soup.find_all("div", class_="page-footer"):
div.decompose()
# Handle image placeholders - replace div.image with actual img tags for proper markdown conversion
for img_div in body_soup.find_all("div", class_="image"):
alt_text = "Image Placeholder" # For now, in the render it's all just a placeholder
# Create an img tag with placeholder src and appropriate alt text
img_tag = body_soup.new_tag("img", src="page.png", alt=alt_text)
img_div.replace_with(img_tag)
# Handle SVG pictures in a similar way, just replace it as an image tag
for svg_tag in body_soup.find_all("svg"):
alt_text = "Graphic Placeholder"
img_tag = body_soup.new_tag("img", src="page.png", alt=alt_text)
svg_tag.replace_with(img_tag)
# Get the modified HTML (only body content)
modified_html = str(body_soup)
# Create custom converter instance
converter = PreserveTablesConverter(
heading_style="ATX", # Use # style headings
bullets="-", # Use - for unordered lists
strip=["a"], # Remove links but keep text
newline_style=SPACES, # Use backslash for line breaks
code_language="", # Don't add language to code blocks
escape_asterisks=False, # Don't escape asterisks
escape_underscores=False, # Don't escape underscores
)
# Convert to markdown
markdown = converter.convert(modified_html)
# Clean up excessive newlines
while "\n\n\n" in markdown:
markdown = markdown.replace("\n\n\n", "\n\n")
# Strip and clean up markdown content
markdown_content = markdown.strip()
# Remove leading or trailing --- if present
while markdown_content.startswith("---"):
markdown_content = markdown_content[3:].strip()
while markdown_content.endswith("---"):
markdown_content = markdown_content[:-3].strip()
# Create FrontMatter
frontmatter = f"""---
primary_language: {metadata['primary_language']}
is_rotation_valid: {metadata['is_rotation_valid']}
rotation_correction: {metadata['rotation_correction']}
is_table: {metadata['is_table']}
is_diagram: {metadata['is_diagram']}
---"""
# Combine FrontMatter with markdown content
if markdown_content:
return f"{frontmatter}\n{markdown_content}"
else:
return frontmatter
async def generate_html_from_image(client, image_base64):
"""Call Claude API to generate HTML from an image using a multi-step prompting strategy."""
global total_input_tokens, total_output_tokens
png_width, png_height = get_png_dimensions_from_base64(image_base64)
try:
# Step 0: Check that the orientation of the original document is right-side-up. If not, we will
# skip this page, to keep the code simple
orientation_response = await call_claude(
client,
model=DEFAULT_MODEL_NAME,
max_tokens=1000,
temperature=0,
messages=[
{
"role": "user",
"content": [
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": image_base64}},
{
"type": "text",
"text": "Please analyze this document image and determine its orientation.\n\n"
"Is this document right-side-up (correctly oriented), or is it rotated?\n\n"
"Make your decision based on the main document contents that takes up most of the page area.\n\n"
"Respond with ONLY one of the following:\n"
"- RIGHT_SIDE_UP: The document is correctly oriented and readable\n"
"- ROTATED_90: The document is rotated 90 degrees clockwise\n"
"- ROTATED_180: The document is upside down (rotated 180 degrees)\n"
"- ROTATED_270: The document is rotated 270 degrees clockwise (90 degrees counter-clockwise)\n"
"- UNCLEAR: Cannot determine orientation (e.g., blank page, purely graphical content)\n\n"
"Important: Only respond with one of these exact terms, nothing else.",
},
],
}
],
)
# Extract orientation from response
orientation_text = ""
for content in orientation_response.content:
if content.type == "text":
orientation_text += content.text.strip()
# Track token usage from orientation check
if hasattr(orientation_response, "usage"):
total_input_tokens += orientation_response.usage.input_tokens
total_output_tokens += orientation_response.usage.output_tokens
# Check orientation result
if "RIGHT_SIDE_UP" not in orientation_text:
print(f"Skipping page due to orientation: {orientation_text}")
return None
# Step 1: Initial analysis and column detection
analysis_response = await call_claude(
client,
model=DEFAULT_MODEL_NAME,
max_tokens=20000,
temperature=0.1,
messages=[
{
"role": "user",
"content": [
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": image_base64}},
{
"type": "text",
"text": "Analyze this document and provide a detailed assessment of its structure. Focus specifically on:\n"
"1. How many columns does the document have? Is it single-column, two-column, three-column, or a mixed layout?\n"
"2. What are the main sections and content types (headings, paragraphs, lists, tables, images, etc.)?\n"
"3. Does it have headers, footers, page numbers, or other special elements?\n"
"4. Is there any complex formatting that would be challenging to reproduce in HTML?\n\n"
"Please be very precise about the number of columns and how they're arranged.",
},
],
}
],
)
# Check if response was complete
if hasattr(analysis_response, "stop_reason") and analysis_response.stop_reason != "end_turn":
print(f"Warning: Analysis response incomplete (stop_reason: {analysis_response.stop_reason})")
return None
analysis_text = ""
for content in analysis_response.content:
if content.type == "text":
analysis_text += content.text
# Track token usage from first API call
if hasattr(analysis_response, "usage"):
total_input_tokens += analysis_response.usage.input_tokens
total_output_tokens += analysis_response.usage.output_tokens
# Step 2: Initial HTML generation with detailed layout instructions
initial_response = await call_claude(
client,
model=DEFAULT_MODEL_NAME,
max_tokens=20000,
temperature=0.2,
messages=[
{
"role": "user",
"content": [
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": image_base64}},
{
"type": "text",
"text": "Render this document as clean, semantic HTML. Here's my analysis of the document structure:\n\n"
f"{analysis_text}\n\n"
"Important requirements:\n"
"1. Use appropriate HTML tags for elements like headings, paragraphs, lists, tables, etc.\n"
"2. Use the <header> and <footer> tags to represent content at the top/bottom which would not normally be part of the main content, such as page numbers, etc.\n"
"3. Use a placeholder <div> tag with class 'image' which will render as a grey box with black outline to make sure images have their original size, shape, and position on the page. Include an alt-text of the original image as a 'data-description' attribute on the tag. Include 'data-x', 'data-y', 'data-width', 'data-height' attributes which specify where the image was found in the original document.\n"
"4. Render any math equations and Latex inline using either \\[ \\] or \\( \\) delimeters.\n"
"5. Render any subscripts and superscripts in <sub> and <sup> tags, not in Unicode characters.\n"
"6. CRITICAL: If the document has a multi-column layout, you MUST preserve the exact same number of columns in your HTML. Use CSS flexbox or grid to create the columns.\n"
"7. Focus on creating valid, accessible HTML that preserves the appearance and formatting of the original page as closely as possible.\n"
f"8. The webpage will be viewed with a fixed viewport size of {png_width} pixels wide by {png_height} pixels tall.\n"
"9. For multi-column layouts, use explicit CSS. The most important aspect is preserving the column structure of the original document - this is critical.\n\n"
"Enclose your HTML in a ```html code block.",
},
],
}
],
)
# Check if response was complete
if hasattr(initial_response, "stop_reason") and initial_response.stop_reason != "end_turn":
print(f"Warning: Initial HTML response incomplete (stop_reason: {initial_response.stop_reason})")
return None
# Extract initial HTML
initial_html_text = ""
for content in initial_response.content:
if content.type == "text":
initial_html_text += content.text
# Track token usage from second API call
if hasattr(initial_response, "usage"):
total_input_tokens += initial_response.usage.input_tokens
total_output_tokens += initial_response.usage.output_tokens
initial_html = extract_code_block(initial_html_text)
if not initial_html:
print("Warning: No HTML code block found in initial response")
return None
# Step 3: Render the initial HTML to PDF and then back to PNG for comparison
# Create a temporary PDF file
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp_pdf:
tmp_pdf_path = tmp_pdf.name
try:
# Render HTML to PDF using existing function
render_result = await render_pdf_with_playwright(initial_html, tmp_pdf_path, png_width, png_height)
if not render_result.success:
print("Warning: Failed to render initial HTML to PDF for refinement")
return None
# Convert PDF back to PNG
rendered_image_base64 = render_pdf_to_base64png(tmp_pdf_path, 1, max(png_width, png_height))
if not rendered_image_base64:
print("Warning: Failed to convert rendered PDF to PNG for refinement")
return None
# We are going to add some stuff to the prompt conditioned on if tables need to be corrected or not
extra_table_fixing_instructions = ""
# Check the tables, if they are non-rectangular, we can apply one more correction pass on them
table_data = parse_html_tables(initial_html)
if any(not table.is_rectangular for table in table_data):
extra_table_fixing_instructions = (
"Important: I've noticed that in the HTML table code, some of the columns/rows are not aligned right. "
"Please work extra hard to make sure the table columns are correctly lined up as in the original document. "
"You can add HTML comments as you output the table to help keep track of the current row and column if needed.\n"
)
# Step 4: Refinement - Show both images to Claude and ask for corrections
refinement_response = await claude_stream(
client,
model=DEFAULT_MODEL_NAME,
max_tokens=40000,
temperature=1.0,
thinking={"type": "enabled", "budget_tokens": 12000},
messages=[
{
"role": "user",
"content": [
{
"type": "text",
"text": "I'm going to show you two images:\n1. The original document\n2. How the HTML I generated renders\n\nPlease compare them carefully and provide a revised version of the HTML that better matches the original.",
},
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": image_base64}},
{"type": "text", "text": "Above is the ORIGINAL document."},
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": rendered_image_base64}},
{"type": "text", "text": "Above is how my HTML currently renders."},
{
"type": "text",
"text": f"Here is the current HTML code:\n\n```html\n{initial_html}\n```\n\n"
"Please analyze the differences between the original document and the rendered version. Focus on:\n"
"1. Layout issues - are columns preserved correctly?\n"
"2. Positioning - are elements in the right place?\n"
"3. Spacing - are margins, padding, and spacing between elements correct?\n"
"4. Occlusion - is any important content hidden or overlapping?\n"
"5. Text formatting - are fonts, sizes, and styles appropriate?\n"
"6. Math - are any Latex math equations delimited by either \\[ \\] or \\( \\) delimeters?\n"
"7. Tables - are the headers on tables are aligned with the correct corresponding columns?\n"
f"{extra_table_fixing_instructions}"
f"The webpage will be viewed at {png_width}x{png_height} pixels.\n\n"
"Provide a REVISED version of the HTML that corrects any issues you identified. "
"Make sure all important elements are visible and the layout matches the original as closely as possible.\n"
"Output the complete revised HTML in a ```html code block.",
},
],
}
],
)
# Check if refinement response was complete
if hasattr(refinement_response, "stop_reason") and refinement_response.stop_reason != "end_turn":
print(f"Warning: Refinement response incomplete (stop_reason: {refinement_response.stop_reason})")
# Return initial HTML as fallback since it was complete
return initial_html
# Extract refined HTML
refined_html_text = ""
for content in refinement_response.content:
if content.type == "text":
refined_html_text += content.text
# Track token usage from refinement API call
if hasattr(refinement_response, "usage"):
total_input_tokens += refinement_response.usage.input_tokens
total_output_tokens += refinement_response.usage.output_tokens
refined_html = extract_code_block(refined_html_text)
final_html = refined_html if refined_html else initial_html
# Check the tables, if they are non-rectangular, we can apply one more correction pass on them
table_data = parse_html_tables(final_html)
if any(not table.is_rectangular for table in table_data):
print("Table not rectangular, aborting")
return None
# Return refined HTML if available, otherwise return initial HTML
return final_html
finally:
# Clean up temporary PDF file
if os.path.exists(tmp_pdf_path):
os.remove(tmp_pdf_path)
except Exception as e:
print(f"Error calling Claude API: {e}")
return None
def extract_page_from_pdf(input_path, output_path, page_num):
"""
Extract a specific page from a PDF and save it as a new PDF.
Args:
input_path: Path to the input PDF
output_path: Path to save the extracted page
page_num: The page number to extract (1-indexed, converted to 0-indexed for pypdf)
Returns:
bool: True if extraction was successful, False otherwise
"""
try:
# Ensure output directory exists
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# Read the input PDF
reader = pypdf.PdfReader(input_path)
# Convert to 0-indexed for pypdf
zero_idx_page = page_num - 1
# Check if page number is valid
if zero_idx_page >= len(reader.pages) or zero_idx_page < 0:
print(f"Page number {page_num} out of range for {input_path} with {len(reader.pages)} pages")
return False
# Create a new PDF with just the selected page
writer = pypdf.PdfWriter()
writer.add_page(reader.pages[zero_idx_page])
# Write the output PDF
with open(output_path, "wb") as output_file:
writer.write(output_file)
return True
except Exception as e:
print(f"Error extracting page {page_num} from {input_path}: {str(e)}")
return False
async def _load_katex_on_page(page):
"""Load KaTeX CSS/JS and run auto-render on an already-loaded Playwright page."""
katex_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "bench", "katex")
katex_css_path = os.path.join(katex_dir, "katex.min.css")
katex_js_path = os.path.join(katex_dir, "katex.min.js")
katex_autorender_js_path = os.path.join(katex_dir, "auto-render.min.js")
await page.add_style_tag(path=katex_css_path)
await page.add_script_tag(path=katex_js_path)
await page.add_script_tag(path=katex_autorender_js_path)
await page.evaluate("""
renderMathInElement(document.body, {
delimiters: [
{left: '\\\\(', right: '\\\\)', display: false},
{left: '\\\\[', right: '\\\\]', display: true}
],
throwOnError: false
});
""")
async def render_pdf_with_playwright(html_content, output_pdf_path, png_width, png_height):
"""
Render HTML content using Playwright and save it as PDF.
First checks for text cutoff (overflow clipping), then tries different
scale factors if needed to ensure the output is exactly one page.
Args:
html_content: HTML content to render
output_pdf_path: Path to save the rendered PDF
png_width: Width of the viewport
png_height: Height of the viewport
Returns:
RenderResult with success, scale_used, cutoff info
"""
scale_factors = [1.0, 0.9, 0.8, 0.7, 0.6, 0.5]
# Determine page format based on PNG dimensions
aspect_ratio = png_width / png_height
pdf_options = {
"path": output_pdf_path,
"print_background": True,
}
if 0.73 <= aspect_ratio <= 0.81: # Letter Portrait
pdf_options["width"] = "8.5in"
pdf_options["height"] = "11in"
elif 1.23 <= aspect_ratio <= 1.35: # Letter Landscape
pdf_options["width"] = "11in"
pdf_options["height"] = "8.5in"
elif 0.67 <= aspect_ratio <= 0.73: # A4 Portrait
pdf_options["width"] = "210mm"
pdf_options["height"] = "297mm"
elif 1.36 <= aspect_ratio <= 1.47: # A4 Landscape
pdf_options["width"] = "297mm"
pdf_options["height"] = "210mm"
async with async_playwright() as p:
browser = await p.chromium.launch()
try:
# Phase 1: Cutoff detection at original viewport size
check_page = await browser.new_page(viewport={"width": png_width, "height": png_height})
await check_page.set_content(html_content, wait_until="load")
await _load_katex_on_page(check_page)
cutoff_elements = await _detect_cutoff_on_page(check_page, 0.9)
await check_page.close()
if has_significant_cutoff(cutoff_elements):
await browser.close()
return RenderResult(
success=False,
cutoff_elements=cutoff_elements,
has_cutoff=True,
)
# Phase 2: Scale loop — render PDF, check page count
for scale in scale_factors:
try:
page = await browser.new_page(
viewport={
"width": int(png_width * scale),
"height": int(png_height * scale),
}
)
await page.set_content(html_content)
await _load_katex_on_page(page)
pdf_options["scale"] = scale
await page.pdf(**pdf_options)
await page.close()
try:
reader = pypdf.PdfReader(output_pdf_path)
if len(reader.pages) == 1:
print(f"Successfully rendered as a single page PDF with scale factor {scale}")
await browser.close()
return RenderResult(success=True, scale_used=scale)
else:
print(f"PDF has {len(reader.pages)} pages with scale factor {scale}, trying a smaller scale...")
except Exception as pdf_check_error:
print(f"Error checking PDF page count: {pdf_check_error}")
await browser.close()
return RenderResult(success=False)
except Exception as e:
print(f"Error rendering PDF with Playwright at scale {scale}: {str(e)}")
except Exception as e:
print(f"Error during render_pdf_with_playwright: {str(e)}")
await browser.close()
return RenderResult(success=False)
await browser.close()
print("Failed to render PDF as a single page with any scale factor")
return RenderResult(success=False)
def generate_tests_from_html(html_content: str, pdf_id: str, page_num: int, random_gen: random.Random, verbose_table_testing: bool = False) -> List[Dict]:
"""
Generate tests from HTML content parsed from the PDF.
Args:
html_content: The HTML content of the page
pdf_id: The unique identifier for the PDF
page_num: The page number
verbose_table_testing: Whether to print table test verification details
Returns:
A list of test dictionaries that can be saved as JSONL
"""
# Use the module-level conversion function
tests = []
pdf_filename = f"{pdf_id}_page{page_num}.pdf"
soup = BeautifulSoup(html_content, "html.parser")
# Remove any divs or spans with class "line-number"
for element in soup.find_all(["div", "span"], class_="line-number"):
element.extract()
# Rewrite any page-header and page-footer divs to be normalized to headers
# Convert div.page-footer to footer in one line
for div in soup.find_all("div", class_="page-header"):
div.name = "header"
for div in soup.find_all("div", class_="page-footer"):
div.name = "footer"
# Remove elements in the body that appear before the header or after the footer
body = soup.find("body")
if body:
header = soup.find("header")
footer = soup.find("footer")
if header:
# Remove elements before the header
current = body.contents[0]
while current and current != header:
next_elem = current.next_sibling
current.extract()
current = next_elem
if footer:
# Remove elements after the footer
current = footer.next_sibling
while current:
next_elem = current.next_sibling
current.extract()
current = next_elem
# Step 1: Process headers, footers, and page numbers for TextAbsenceTests
headers = soup.find_all("header")
footers = soup.find_all("footer")
page_numbers = soup.find_all("div", class_="page-number")
# Function to create absence tests from text elements
def create_absence_tests_from_elements(parent_element, element_type):
mini_soup = BeautifulSoup(str(parent_element), "html.parser")
# Convert superscripts and subscripts in the mini soup
convert_superscripts_subscripts(mini_soup)
# Remove headers, footers, and tables from the main_soup
for element in mini_soup.find_all(["h1", "h2"]):
element.extract()
# Find all text-containing leaf elements within the parent
text_elements = []
# Get all target elements
target_tags = mini_soup.find_all(["span", "div", "p", "h3", "h4", "h5", "h6"])
# Filter to only include leaf nodes (elements that don't contain other target elements)
for tag in target_tags:
# Check if this element has no children from our target tags
is_leaf = not tag.find(["span", "div", "p", "h3", "h4", "h5", "h6"])
if is_leaf:
text = tag.get_text().strip()
if text:
text_elements.append(text)
# If no elements found, use the parent's text as a fallback, but only if
if not text_elements:
parent_text = mini_soup.get_text().strip()
if parent_text:
text_elements.append(parent_text)
# Create tests for each text element
for text in text_elements:
if "\n" in text:
text = text.split("\n")[0]
if len(text) > 3 or len([c for c in text if c.isdigit()]): # Only create tests for meaningful text
tests.append(
{
"pdf": pdf_filename,
"page": 1,
"id": f"{pdf_id}_{element_type}_{uuid.uuid4().hex[:8]}",
"type": TestType.ABSENT.value,
"text": text,
"max_diffs": round(len(text) * 0.05),
}
)
# Create TextAbsenceTests for headers
for header in headers:
create_absence_tests_from_elements(header, "header")
# Create TextAbsenceTests for footers
for footer in footers:
create_absence_tests_from_elements(footer, "footer")
# Create TextAbsenceTests for page numbers
for page_number in page_numbers:
# Convert any superscripts/subscripts in the page number
page_number_soup = BeautifulSoup(str(page_number), "html.parser")
convert_superscripts_subscripts(page_number_soup)
page_number_text = page_number_soup.get_text().strip()
if page_number_text:
tests.append(
{
"pdf": pdf_filename,
"page": 1,
"id": f"{pdf_id}_page_number_{uuid.uuid4().hex[:8]}",
"type": TestType.ABSENT.value,
"text": page_number_text,
"max_diffs": 0,
}
)
# Step 2: Generate tests from tables using parse_html_tables
# Convert superscripts and subscripts to Unicode equivalents in tables
table_soup = BeautifulSoup(html_content, "html.parser")
# Convert superscripts and subscripts in the table HTML
convert_superscripts_subscripts(table_soup)
html_content_with_unicode = str(table_soup)
table_data_list = parse_html_tables(html_content_with_unicode)
for table_idx, table_data in enumerate(table_data_list):
# Get the table data as a numpy array
table_tests = []
# Skip tables that are too small
if max(x[0] for x in table_data.cell_text.keys()) < 2:
continue
if max(x[1] for x in table_data.cell_text.keys()) < 2:
continue
all_known_cells = list(table_data.cell_text.items())
random_gen.shuffle(all_known_cells)
for rowcol, cell_content in all_known_cells:
cell_content = normalize_text(cell_content)
if len(cell_content) == 0:
continue
# Create a TableTest with relevant relationships
test_data = {
"pdf": pdf_filename,
"page": 1,
"id": f"{pdf_id}_table{table_idx}_{uuid.uuid4().hex[:8]}",
"type": TestType.TABLE.value,
"cell": cell_content,
"max_diffs": 0,
"ignore_markdown_tables": True,
}
if rowcol in table_data.up_relations and len(table_data.up_relations[rowcol]) > 0:
relation = random_gen.choice(list(table_data.up_relations[rowcol]))
if len(table_data.cell_text[relation].strip()) > 0 and "\n" not in table_data.cell_text[relation]:
test_data["up"] = normalize_text(table_data.cell_text[relation])
if rowcol in table_data.down_relations and len(table_data.down_relations[rowcol]) > 0:
relation = random_gen.choice(list(table_data.down_relations[rowcol]))
if len(table_data.cell_text[relation].strip()) > 0 and "\n" not in table_data.cell_text[relation]:
test_data["down"] = normalize_text(table_data.cell_text[relation])
if rowcol in table_data.left_relations and len(table_data.left_relations[rowcol]) > 0:
relation = random_gen.choice(list(table_data.left_relations[rowcol]))
if len(table_data.cell_text[relation].strip()) > 0 and "\n" not in table_data.cell_text[relation]:
test_data["left"] = normalize_text(table_data.cell_text[relation])
if rowcol in table_data.right_relations and len(table_data.right_relations[rowcol]) > 0:
relation = random_gen.choice(list(table_data.right_relations[rowcol]))
if len(table_data.cell_text[relation].strip()) > 0 and "\n" not in table_data.cell_text[relation]:
test_data["right"] = normalize_text(table_data.cell_text[relation])
if len(table_data.left_heading_relations(*rowcol)) > 0:
relation = random_gen.choice(list(table_data.left_heading_relations(*rowcol)))
if len(table_data.cell_text[relation].strip()) > 0 and "\n" not in table_data.cell_text[relation]:
test_data["left_heading"] = normalize_text(table_data.cell_text[relation])
if len(table_data.top_heading_relations(*rowcol)) > 0:
relation = random_gen.choice(list(table_data.top_heading_relations(*rowcol)))
if len(table_data.cell_text[relation].strip()) > 0 and "\n" not in table_data.cell_text[relation]:
test_data["top_heading"] = normalize_text(table_data.cell_text[relation])
# Only add the test if we have at least one relation
if any(x in test_data for x in ["up", "down", "left", "right", "top_heading", "left_heading"]):
# Verify that the test passes with the current table HTML
# Create the actual test object
test_obj = TableTest(
pdf=test_data["pdf"],
page=test_data["page"],
id=test_data["id"],
type=test_data["type"],
cell=test_data["cell"],
max_diffs=test_data["max_diffs"],
up=test_data.get("up", ""),
down=test_data.get("down", ""),
left=test_data.get("left", ""),
right=test_data.get("right", ""),
top_heading=test_data.get("top_heading", ""),
left_heading=test_data.get("left_heading", ""),
)
# Extract just the relevant table HTML
tables = soup.find_all("table")
if table_idx < len(tables):
table_html = str(tables[table_idx])
# Run the test against the original HTML
passed, explanation = test_obj.run(table_html)
else:
# Shouldn't happen, but handle it gracefully
passed = False
# Only add tests that pass
if passed:
table_tests.append(test_data)
if len(table_tests) > 25:
break
# Done with inner for loop iterating over cells
# So add in the bulk of the test cases back in now
tests.extend(table_tests)
# Step 3: Generate TextPresenceTests and OrderingTests from markdown content
# Convert HTML to markdown to get cleaner text for presence and ordering tests
full_markdown_content = html_to_markdown_with_frontmatter(html_content)
# Extract language from HTML metadata for wordfreq
metadata = extract_html_metadata(html_content)
primary_language = metadata.get("primary_language", "en")
# Remove any HTML tables from the markdown content
# Tables can persist in markdown as raw HTML and we want to exclude them
stripped_markdown_content = re.sub(r"<table[^>]*>.*?</table>", "", full_markdown_content, flags=re.DOTALL | re.IGNORECASE)
# Remove math equations from this markdown content
# Remove $$...$$ blocks (display math)
stripped_markdown_content = re.sub(r"\$\$.*?\$\$", "", stripped_markdown_content, flags=re.DOTALL)
# Remove \[...\] blocks (display math)
stripped_markdown_content = re.sub(r"\\\[.*?\\\]", "", stripped_markdown_content, flags=re.DOTALL)
# Remove \(...\) blocks (inline math)
stripped_markdown_content = re.sub(r"\\\(.*?\\\)", "", stripped_markdown_content, flags=re.DOTALL)
# Extract just the content part (after frontmatter)
markdown_lines = stripped_markdown_content.split("\n")
content_start_idx = 0
# Skip frontmatter if present
if markdown_lines[0] == "---":
for idx, line in enumerate(markdown_lines[1:], 1):
if line == "---":
content_start_idx = idx + 1
break
# Get markdown content without frontmatter
markdown_text = "\n".join(markdown_lines[content_start_idx:]).strip()
# Parse sentences from markdown content
sentences = []
if markdown_text:
for paragraph in process(markdown_text):
for sentence in paragraph:
# Convert token sequence to string and clean it
sentence_str = ""
for token in sentence:
sentence_str += token.spacing + token.value
sentence_str = sentence_str.strip()
if sentence_str:
# Skip HTML content that might still be in markdown
if not sentence_str.startswith("<") and not sentence_str.endswith(">"):
# Skip image placeholders - match any markdown image syntax ![...](...)
if re.search(r"!\[.*?\]\(.*?\)", sentence_str):
continue
# Remove leading # marks (markdown headers)
while sentence_str.startswith("#"):
sentence_str = sentence_str[1:]
sentence_str = sentence_str.strip()
# Remove leading "- " for unordered lists
if sentence_str.startswith("- "):
sentence_str = sentence_str[2:]
sentence_str = normalize_text(sentence_str.strip())
if sentence_str: # Only add if there's still content after cleaning
sentences.append(sentence_str)
# Add a few random ordering tests
all_indexes = list(range(len(sentences)))
random_gen.shuffle(all_indexes)
random_pairs = [(all_indexes[i * 2], all_indexes[i * 2 + 1]) for i in range(len(all_indexes) // 2)]
random_pairs = [(min(i, j), max(i, j)) for (i, j) in random_pairs]
num_order_tests = 0
for i, j in random_pairs:
first_sentence = sentences[i]
second_sentence = sentences[j]
if len(first_sentence) < 5 or len(second_sentence) < 5:
continue
if "\n" in first_sentence:
first_sentence = first_sentence.split("\n")[0].strip()
if "\n" in second_sentence:
second_sentence = second_sentence.split("\n")[0].strip()
max_diffs = round(max(len(first_sentence), len(second_sentence)) * 0.02)
# Too big of a length discrepancy causes issues
if max_diffs > len(first_sentence) // 4 or max_diffs > len(second_sentence) // 4:
continue
test_data = {
"pdf": pdf_filename,
"page": 1,
"id": f"{pdf_id}_order_{uuid.uuid4().hex[:8]}",
"type": TestType.ORDER.value,
"before": first_sentence,
"after": second_sentence,
"max_diffs": max_diffs,
}
# Create test object to validate
try:
test_obj = TextOrderTest(**test_data)
# Run the test against the markdown content
passed, _ = test_obj.run(full_markdown_content)
if passed:
num_order_tests += 1
tests.append(test_data)
except Exception:
# Skip if test creation or validation fails
pass
if num_order_tests > 5:
break
# Go through the top 10 rarest words (any chars permitted), and also the top 5 rarest numbers (only numeric chars allowed)
# And add those as TestType.PRESENT.value, and also run the test to check that it passes before adding
word_counter = Counter()
number_counter = Counter()
word_rarities = {} # Store Zipf frequencies for words
# Split on whitespace and check each token
# We use the clean set of sentences used in the order test creation, which have been line-by-line normalized
tokens = "\n".join(sentences).split()
# Pattern for numbers: optional minus, digits, optional decimal point and more digits
number_pattern = re.compile(r"^-?\d+(?:\.\d+)?$")
for token in tokens:
# Strip common punctuation from ends
token_cleaned = token.strip(".,;:!?\"'()")
if not token_cleaned:
continue
# Check if it's a number
if number_pattern.match(token_cleaned):
if len(token_cleaned) >= 2: # Only count numbers with at least 2 characters
number_counter[token_cleaned] += 1
# Check if it's a word (has at least one alphabetic character)
elif any(c.isalpha() for c in token_cleaned):
token_lower = token_cleaned.lower()
if len(token_lower) >= 4: # Only count words with at least 4 characters
word_counter[token_lower] += 1
# Calculate Zipf frequency if wordfreq is available
if token_lower not in word_rarities:
# Get Zipf frequency (0-8 scale, higher = more common)
# Use primary_language if it's a valid 2-letter code
lang_code = primary_language if len(primary_language) == 2 else "en"
try:
zipf = zipf_frequency(token_lower, lang_code)
word_rarities[token_lower] = zipf
except:
# If language not supported or error, use default
word_rarities[token_lower] = 4.0 # Assume moderately common
# Collect rarest items first
rarest_items = []
if word_rarities:
# Filter for truly rare words (Zipf frequency ≤ 3)
rare_words_with_zipf = [(word, zipf) for word, zipf in word_rarities.items() if zipf <= 3.0]
# Sort by Zipf frequency (ascending = rarest first), then by count
rare_words_with_zipf.sort(key=lambda x: (x[1], word_counter[x[0]]))
# Take up to 10 rarest words
rarest_words = [word for word, _ in rare_words_with_zipf[:10]]
else:
# Fallback to old method if wordfreq not available
# Get the 10 least common words
sorted_words = sorted(word_counter.items(), key=lambda x: x[1])
rarest_words = [word for word, _ in sorted_words[:10]]
# Shuffle the rarest words
random_gen.shuffle(rarest_words)
# Add rarest words to the list with their type
for word in rarest_words:
rarest_items.append((word, "present_word"))
# Get the 5 least common numbers
sorted_numbers = sorted(number_counter.items(), key=lambda x: x[1])
rarest_numbers = [num for num, _ in sorted_numbers[:5]]
# Shuffle the rarest numbers
random_gen.shuffle(rarest_numbers)
# Add rarest numbers to the list with their type
for number in rarest_numbers:
rarest_items.append((number, "present_num"))
# Now create and validate presence tests for all rare items
for test_text, test_id_prefix in rarest_items:
# Normalize the test text
normalized_text = normalize_text(test_text)
if not normalized_text or len(normalized_text) < 2:
continue
# Create test data
test_data = {
"pdf": pdf_filename,
"page": 1,
"id": f"{pdf_id}_{test_id_prefix}_{uuid.uuid4().hex[:8]}",
"type": TestType.PRESENT.value,
"text": test_text,
"max_diffs": 0,
}
# Create test object to validate
try:
test_obj = TextPresenceTest(
pdf=test_data["pdf"],
page=test_data["page"],
id=test_data["id"],
type=test_data["type"],
text=test_data["text"],
max_diffs=test_data["max_diffs"],
)
# Run the test against the markdown content
passed, _ = test_obj.run(full_markdown_content)
if passed:
tests.append(test_data)
except Exception:
# Skip if test creation or validation fails
pass
# Step 3.5: Generate absence tests for 3 randomly selected common words that don't appear on the page
# Get the top 1000 most common words using wordfreq
# Note from Jake: For now, I am commenting this out, I can't just help shake this feeling that this would
# incentivize the model in slightly the wrong way somehow.
# lang_code = primary_language if len(primary_language) == 2 else 'en'
# try:
# # Get top 1000 common words
# common_words_list = top_n_list(lang_code, 1000)
# except:
# # Fallback to English if language not supported
# common_words_list = top_n_list('en', 1000)
# # Build a set of words that appear on the page (lowercase)
# page_words_set = set(word.lower() for word in word_counter.keys())
# # Find common words not on the page
# absent_common_words = []
# for word in common_words_list:
# if word.lower() not in page_words_set and len(word) >= 2: # Skip if word is on page
# # Get the Zipf frequency to ensure it's truly common
# try:
# zipf = zipf_frequency(word, lang_code)
# absent_common_words.append((word, zipf))
# except:
# # If error getting Zipf frequency, still include with a high assumed frequency
# absent_common_words.append((word, 6.0))
# # Select 3 absent common words that are most similar to words on the page
# if len(absent_common_words) > 0 and len(page_words_set) > 0:
# from rapidfuzz import fuzz
# # Calculate similarity scores for each absent word
# absent_words_with_similarity = []
# page_words_list = list(page_words_set) # Convert to list for iteration
# for word, zipf_freq in absent_common_words:
# # Calculate max similarity to any word on the page
# max_similarity = 0
# for page_word in page_words_list:
# similarity = fuzz.ratio(word.lower(), page_word)
# if similarity > max_similarity:
# max_similarity = similarity
# absent_words_with_similarity.append((word, zipf_freq, max_similarity))
# # Sort by similarity score (descending) to get words most similar to page content
# absent_words_with_similarity.sort(key=lambda x: x[2], reverse=True)
# # Select top 3 most similar words
# num_to_select = min(3, len(absent_words_with_similarity))
# selected_absent_words = absent_words_with_similarity[:num_to_select]
# # Create absence tests for these selected common words
# for word, zipf_freq, similarity_score in selected_absent_words:
# test_data = {
# "pdf": pdf_filename,
# "page": 1,
# "id": f"{pdf_id}_absent_common_{uuid.uuid4().hex[:8]}",
# "type": TestType.ABSENT.value,
# "text": word,
# "max_diffs": 0,
# "case_sensitive": False,
# }
# # Double-check the word really doesn't appear in the markdown text
# # For ABSENT tests, we want to ensure the word is NOT found
# try:
# validation_test = TextPresenceTest(**test_data)
# # Run the validation test against the markdown content
# passed, _ = validation_test.run(markdown_text)
# if passed:
# tests.append(test_data)
# except Exception:
# # Skip if test creation or validation fails
# pass
# Step 4: Generate Math tests for LaTeX equations from the markdown
# Define math patterns to search for
math_patterns = [
(r"\$\$(.+?)\$\$", re.DOTALL), # $$...$$ (multiline)
(r"\\\((.+?)\\\)", re.DOTALL), # \(...\) (multiline)
(r"\\\[(.+?)\\\]", re.DOTALL), # \[...\] (multiline)
]
math_equations = []
for pattern, flags in math_patterns:
matches = re.findall(pattern, full_markdown_content, flags)
for match in matches:
# Clean up the match - remove extra whitespace and newlines
equation = match.strip()
# Skip empty or very short equations
if len(equation) > 2:
math_equations.append(equation)
# Remove duplicates while preserving order
seen = set()
unique_equations = []
for eq in math_equations:
if eq not in seen:
seen.add(eq)
unique_equations.append(eq)
# Create math tests for up to 50 unique equations
for i, equation in enumerate(unique_equations[:50]):
tests.append(
{
"pdf": pdf_filename,
"page": 1,
"id": f"{pdf_id}_math_{uuid.uuid4().hex[:8]}",
"type": "math",
"math": equation,
"max_diffs": 0,
"ignore_dollar_delimited": True,
}
)
# Step 5: Generate FormatTests for headings, bold, and italic text
format_tests = []
# Define mapping from HTML tags to format types
format_tag_mapping = {("h1", "h2", "h3", "h4", "h5", "h6"): "heading", ("b", "strong"): "bold", ("i", "em"): "italic"}
# Parse the HTML to find formatted elements
format_soup = BeautifulSoup(html_content, "html.parser")
# Convert superscripts and subscripts before extracting text
convert_superscripts_subscripts(format_soup)
# Remove headers, footers, and tables from format soup to focus on main content
for element in format_soup.find_all(["header", "footer", "table"]):
element.decompose()
# Track what we've already tested to avoid duplicates
tested_texts = set()
# Process each format type
for tags, format_type in format_tag_mapping.items():
# Find all elements with these tags
elements = format_soup.find_all(list(tags))
for element in elements:
if len(format_tests) >= 5: # Limit to 5 total format tests
break
element_text = element.get_text().strip()
element_text = normalize_text(element_text)
if element_text and len(element_text) >= 3 and element_text not in tested_texts:
# Create a format test
test_data = {
"pdf": pdf_filename,
"page": 1,
"id": f"{pdf_id}_format_{format_type}_{uuid.uuid4().hex[:8]}",
"type": TestType.FORMAT.value,
"text": element_text,
"format": format_type,
"max_diffs": round(len(element_text) * 0.05),
}
# Validate the test against markdown_content
try:
test_obj = FormatTest(
pdf=test_data["pdf"],
page=test_data["page"],
id=test_data["id"],
type=test_data["type"],
text=test_data["text"],
format=test_data["format"],
max_diffs=test_data["max_diffs"],
)
# Test against the markdown_content
passed, _ = test_obj.run(full_markdown_content)
if passed:
format_tests.append(test_data)
tested_texts.add(element_text)
except Exception:
pass
# Add format tests to the main tests list
tests.extend(format_tests)
# Step 6: Generate FootnoteTests for footnotes on the page
footnote_tests = []
# Parse the HTML to find footnotes (without converting superscripts)
footnote_soup = BeautifulSoup(html_content, "html.parser")
cleanup_headers_footers_soup(footnote_soup)
# Look for superscript elements that might be footnote markers
sup_elements = footnote_soup.find_all("sup")
max_footnote_tests = 5
marker_sup_map = {}
for sup in sup_elements:
marker_text = sup.get_text().strip()
# Filter out markers that are unlikely to be footnote references
if not marker_text or not (marker_text.isdigit() or (len(marker_text) == 1 and marker_text.isalpha()) or marker_text in ["*", "†", "‡", "§", "¶"]):
continue
if marker_text not in marker_sup_map:
if len(marker_sup_map) >= max_footnote_tests:
continue
marker_sup_map[marker_text] = []
marker_sup_map[marker_text].append(sup)
for marker_text, marker_superscripts in marker_sup_map.items():
test_data = {
"pdf": pdf_filename,
"page": 1,
"id": f"{pdf_id}_footnote_{uuid.uuid4().hex[:8]}",
"type": TestType.FOOTNOTE.value,
"marker": marker_text,
"max_diffs": 0,
}
# Extract text context for each occurrence
occurrences = []
for sup in marker_superscripts:
parent = sup.parent
if not parent:
continue
# Get text before and after the sup element by reconstructing parent content
before_text = None
after_text = None
# Get all content before this sup element
text_before_sup = []
for sibling in parent.children:
if sibling == sup:
break
if isinstance(sibling, str):
text_before_sup.append(sibling)
else:
text_before_sup.append(sibling.get_text())
# Get all content after this sup element
found_sup = False
text_after_sup = []
for sibling in parent.children:
if found_sup:
if isinstance(sibling, str):
text_after_sup.append(sibling)
else:
text_after_sup.append(sibling.get_text())
elif sibling == sup:
found_sup = True
# Process text before marker
preceding_text = "".join(text_before_sup).strip()
if len(preceding_text) >= 10:
words = preceding_text.split()
if len(words) >= 2:
last_words = " ".join(words[-3:]) if len(words) >= 3 else " ".join(words)
else:
last_words = preceding_text
candidate = normalize_text(last_words)
if candidate and len(candidate) >= 5:
before_text = candidate
# Process text after marker
following_text = "".join(text_after_sup).strip()
if len(following_text) >= 10:
words = following_text.split()
if len(words) >= 2:
first_words = " ".join(words[:3]) if len(words) >= 3 else " ".join(words)
else:
first_words = following_text[:50]
candidate = normalize_text(first_words)
if candidate and len(candidate) >= 5:
after_text = candidate
occurrences.append({"before": before_text, "after": after_text})
# Apply logic based on number of occurrences
if len(occurrences) >= 2:
# If marker exists 2+ times: use before from first, after from second
if occurrences[0]["before"]:
test_data["appears_before_marker"] = occurrences[0]["before"]
if occurrences[1]["after"]:
test_data["appears_after_marker"] = occurrences[1]["after"]
elif len(occurrences) == 1:
# If marker exists 1 time: prefer left text, then right text
if occurrences[0]["before"]:
test_data["appears_before_marker"] = occurrences[0]["before"]
elif occurrences[0]["after"]:
test_data["appears_after_marker"] = occurrences[0]["after"]
# If neither before nor after text exists, we'll just have the marker
# Create test even if we only have the marker (no additional fields required)
try:
test_obj = FootnoteTest(**test_data)
passed, _ = test_obj.run(full_markdown_content)
if passed:
footnote_tests.append(test_data)
except Exception:
pass
# Add footnote tests to the main tests list
tests.extend(footnote_tests)
# Final test filtering out stage
# Now double check that the absent tests don't find any matches in the markdown_text
# If they do, filter them out
tests = [t for t in tests if t["type"] != "absent" or t["text"] not in markdown_text]
# Remove any tests where text-based fields have no alphanumeric characters, contain LaTeX, or contain Unicode super/subscripts
text_fields = ["text", "cell", "before", "after", "up", "down", "left", "right", "top_heading", "left_heading"]
def contains_alphanumeric(value):
return any(c.isalnum() for c in value) if isinstance(value, str) else False
def contains_latex(value):
if not isinstance(value, str):
return False
# Check for LaTeX delimiters
latex_patterns = [r"\(", r"\)", r"\[", r"\]"]
return any(pattern in value for pattern in latex_patterns)
def contains_unicode_super_or_subscripts(value):
if not isinstance(value, str):
return False
# Unicode ranges for superscripts and subscripts
superscript_chars = "⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾ⁿⁱ"
subscript_chars = "₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎ₐₑₒₓₕₖₗₘₙₚₛₜ"
return any(c in superscript_chars or c in subscript_chars for c in value)
def contains_html_sup_sub_tags(value):
if not isinstance(value, str):
return False
# Check for HTML sup/sub tags
return "<sup>" in value or "</sup>" in value or "<sub>" in value or "</sub>" in value
filtered_tests = []
for test in tests:
# Math tests should not be filtered for LaTeX content
if test.get("type") == "math":
filtered_tests.append(test)
continue
# Format tests have different validation requirements
if test.get("type") == TestType.FORMAT.value:
# Only check the "text" field for format tests
if "text" in test:
if contains_alphanumeric(test["text"]) and not contains_latex(test["text"]) and not contains_unicode_super_or_subscripts(test["text"]):
filtered_tests.append(test)
continue
# Footnote tests have special requirements
if test.get("type") == TestType.FOOTNOTE.value:
# Markers can contain superscript characters, so don't filter them
# But appears_before_marker and appears_after_marker should not contain LaTeX
valid = True
if "appears_before_marker" in test and test["appears_before_marker"]:
if not contains_alphanumeric(test["appears_before_marker"]) or contains_latex(test["appears_before_marker"]):
valid = False
if "appears_after_marker" in test and test["appears_after_marker"]:
if not contains_alphanumeric(test["appears_after_marker"]) or contains_latex(test["appears_after_marker"]):
valid = False
if valid:
filtered_tests.append(test)
continue
# Check all text fields in the test for alphanumeric content, LaTeX, Unicode super/subscripts, and HTML tags
all_valid = True
for field in text_fields:
if field in test:
# Skip test if field has no alphanumeric characters
if not contains_alphanumeric(test[field]):
all_valid = False
break
# Skip test if field contains LaTeX delimiters
if contains_latex(test[field]):
all_valid = False
break
# Skip test if field contains Unicode super or subscripts
if contains_unicode_super_or_subscripts(test[field]):
all_valid = False
break
# Skip test if field contains HTML sup/sub tags
if contains_html_sup_sub_tags(test[field]):
all_valid = False
break
if all_valid:
filtered_tests.append(test)
tests = filtered_tests
# Remove duplicate tests (identical on everything but the id field)
unique_tests = []
test_signatures = set()
for test in tests:
# Create a signature for the test by using all fields except 'id'
test_dict = test.copy()
test_dict.pop("id")
# Convert dict to a sorted tuple of items for hashability
test_signature = tuple(sorted((k, str(v)) for k, v in test_dict.items()))
# Only add the test if we haven't seen an identical one
if test_signature not in test_signatures:
test_signatures.add(test_signature)
unique_tests.append(test)
# Add a single BaselineTest for this page
# First, check if the markdown content would fail due to disallowed characters
baseline_test_data = {
"pdf": pdf_filename,
"page": 1,
"id": f"{pdf_id}_baseline_{uuid.uuid4().hex[:8]}",
"type": "baseline",
"max_repeats": 30,
"check_disallowed_characters": True,
}
# Create a baseline test object to check if disallowed characters are present
try:
baseline_test_obj = BaselineTest(
pdf=baseline_test_data["pdf"],
page=baseline_test_data["page"],
id=baseline_test_data["id"],
type=baseline_test_data["type"],
max_repeats=baseline_test_data["max_repeats"],
check_disallowed_characters=True,
)
# Run the test with check_disallowed_characters=True
passed, explanation = baseline_test_obj.run(full_markdown_content)
# If it failed due to disallowed characters, set check_disallowed_characters=False
if not passed and "disallowed characters" in explanation:
baseline_test_data["check_disallowed_characters"] = False
except Exception:
# If there was an error creating/running the test, keep default settings
pass
# Add the baseline test to the list
unique_tests.append(baseline_test_data)
return unique_tests
def check_outputs_exist(pdf_id: str, page_num: int, args, existing_test_pdfs: set) -> bool:
"""
Check if all output files for a given PDF already exist.
Returns True if all outputs exist and processing can be skipped.
"""
# Define expected output paths
html_dir = os.path.join(args.output_dir, "html", args.name)
pdfs_dir = os.path.join(args.output_dir, "pdfs", args.name)
training_dir = os.path.join(args.output_dir, "training", args.name)
bench_data_dir = os.path.join(args.output_dir, "bench_data")
claude_original_dir = os.path.join(bench_data_dir, "claude_original", args.name)
base_filename = f"{pdf_id}_page{page_num}"
# Check HTML file
html_path = os.path.join(html_dir, f"{base_filename}.html")
if not os.path.exists(html_path):
return False
# Check rendered PDF in pdfs dir
pdf_path = os.path.join(pdfs_dir, f"{base_filename}.pdf")
if not os.path.exists(pdf_path):
return False
# Check markdown in training dir
markdown_path = os.path.join(training_dir, f"{base_filename}.md")
if not os.path.exists(markdown_path):
return False
# Check symlink in training dir
pdf_link_path = os.path.join(training_dir, f"{base_filename}.pdf")
if not os.path.exists(pdf_link_path) and not os.path.islink(pdf_link_path):
return False
# Check claude_original symlink
claude_md_link_path = os.path.join(claude_original_dir, f"{base_filename}_pg1_repeat1.md")
if not os.path.exists(claude_md_link_path) and not os.path.islink(claude_md_link_path):
return False
# Check that bench data has at least one test for this PDF
expected_test_pdf = f"{args.name}/{base_filename}.pdf"
if expected_test_pdf not in existing_test_pdfs:
return False
return True
def load_existing_test_pdfs(args) -> set:
"""
Load existing test PDFs from the bench data JSONL file.
Returns a set of PDF paths that already have tests.
"""
bench_data_dir = os.path.join(args.output_dir, "bench_data")
synthetic_json_path = os.path.join(bench_data_dir, f"{args.name}.jsonl")
existing_pdfs = set()
if os.path.exists(synthetic_json_path):
try:
with open(synthetic_json_path, "r") as f:
for line in f:
line = line.strip()
if line:
try:
test = json.loads(line)
if "pdf" in test:
existing_pdfs.add(test["pdf"])
except json.JSONDecodeError:
continue
except Exception as e:
print(f"Warning: Could not read existing bench data: {e}")
return existing_pdfs
async def process_pdf(pdf_info, args, client, pdf_filter=None, existing_test_pdfs=None):
"""Process a single PDF, render a random page, and create an HTML template."""
pdf_path, index = pdf_info
# Create a unique folder for each PDF in the temp directory; include PID and PDF path hash to avoid cross-run collisions
pdf_id = f"pdf_{index:05d}"
temp_dir_suffix = f"{os.getpid()}_{hashlib.sha1(pdf_path.encode('utf-8')).hexdigest()[:16]}"
temp_pdf_dir = os.path.join(args.temp_dir, f"{pdf_id}_{temp_dir_suffix}")
os.makedirs(temp_pdf_dir, exist_ok=True)
# Determine if we should log table test verification
verbose_table_testing = args.verbose
# Download PDF to local temp directory (or copy if local)
local_pdf_path = os.path.join(temp_pdf_dir, "document.pdf")
if not download_s3_pdf(pdf_path, local_pdf_path):
print(f"Failed to download/copy PDF from {pdf_path}")
return None
# Apply filter if enabled
if pdf_filter and pdf_filter.filter_out_pdf(local_pdf_path):
print(f"PDF filtered out: {pdf_path}")
return None
# Seed with SHA1 hash of PDF contents for reproducibility
with open(local_pdf_path, "rb") as f:
pdf_content = f.read()
pdf_hash = hashlib.sha1(pdf_content).hexdigest()
# Use the first 8 characters of the hash as an integer seed
seed = int(pdf_hash[:8], 16)
random_generator = random.Random(seed)
try:
# Get page count using pypdf
reader = pypdf.PdfReader(local_pdf_path)
num_pages = len(reader.pages)
if num_pages == 0:
print(f"PDF has no pages: {pdf_path}")
return None
# Select a random page
page_num = random_generator.randint(1, num_pages)
# Check if this PDF has already been processed (skip logic for resume)
if existing_test_pdfs is not None and check_outputs_exist(pdf_id, page_num, args, existing_test_pdfs):
print(f"Skipping {pdf_id} page {page_num} - already processed")
return {"skipped": True, "pdf_id": pdf_id, "page_number": page_num}
# Render the page as a base64 PNG (run in thread pool since it's blocking I/O)
loop = asyncio.get_event_loop()
image_base64 = await loop.run_in_executor(None, render_pdf_to_base64png, local_pdf_path, page_num, 1024)
# Generate HTML from the image
html_content = await generate_html_from_image(client, image_base64)
if not html_content:
print(f"Failed to generate HTML for {pdf_path}, page {page_num}")
return None
if args.densify:
from olmocr.synth.augmentations import densify_html
html_content = await densify_html(client, html_content)
if not html_content:
print(f"Failed to densify HTML for {pdf_path}, page {page_num}")
return None
typo_records = []
pre_typo_html = None
if args.introduce_text_errors > 0:
from olmocr.synth.augmentations import introduce_text_errors
pre_typo_html = html_content
html_content, typo_records = introduce_text_errors(html_content, random_generator, num_errors=args.introduce_text_errors)
# Add git commit meta tag if available
git_commit = get_git_commit_hash()
if git_commit:
# Parse the HTML to add the meta tag in the head section
html_soup = BeautifulSoup(html_content, "html.parser")
# Only add meta tag if head element exists
head = html_soup.find("head")
if head:
# Add meta tag with git commit
meta_tag = html_soup.new_tag("meta", attrs={"name": "olmocr_git_commit", "content": git_commit})
head.append(meta_tag)
# Update initial_html with the modified version
html_content = str(html_soup)
# Create output directories
html_dir = os.path.join(args.output_dir, "html", args.name)
pdfs_dir = os.path.join(args.output_dir, "pdfs", args.name)
training_dir = os.path.join(args.output_dir, "training", args.name)
bench_data_dir = os.path.join(args.output_dir, "bench_data")
bench_synthetic_dir = os.path.join(bench_data_dir, "pdfs", args.name)
claude_original_dir = os.path.join(bench_data_dir, "claude_original", args.name)
os.makedirs(html_dir, exist_ok=True)
os.makedirs(pdfs_dir, exist_ok=True)
os.makedirs(training_dir, exist_ok=True)
os.makedirs(bench_data_dir, exist_ok=True)
os.makedirs(bench_synthetic_dir, exist_ok=True)
os.makedirs(claude_original_dir, exist_ok=True)
# Render PDF using Playwright
playwright_pdf_path = None
render_result = None
playwright_pdf_filename = f"{pdf_id}_page{page_num}.pdf" # This will be used in the tests
playwright_pdf_path = os.path.join(pdfs_dir, playwright_pdf_filename)
try:
# Get PNG dimensions
png_width, png_height = get_png_dimensions_from_base64(image_base64)
# Run the async function directly since we're already in an async context
render_result = await render_pdf_with_playwright(html_content, playwright_pdf_path, png_width, png_height)
if render_result.success:
print(f"Successfully rendered with Playwright: {playwright_pdf_path}")
# Apply JPEG compression if requested
if args.jpegify:
# Select a random quality level between 70 and 95
jpeg_quality = random_generator.randint(70, 95)
print(f"Applying JPEG compression with quality {jpeg_quality} to {playwright_pdf_path}")
from olmocr.synth.augmentations import apply_jpeg_compression
compression_success = apply_jpeg_compression(playwright_pdf_path, jpeg_quality, temp_pdf_dir)
if compression_success:
print(f"Successfully applied JPEG compression with quality {jpeg_quality}")
else:
print(f"Warning: Failed to apply JPEG compression, keeping original PDF")
else:
if render_result.has_cutoff:
print(f"Skipping: text cutoff detected in {playwright_pdf_path}")
else:
print(f"Failed to render as a single page PDF: {playwright_pdf_path}")
playwright_pdf_path = None
except Exception as e:
print(f"Failed to render with Playwright: {e}")
playwright_pdf_path = None
render_result = None
# If playwright rendering failed and was required, return None to skip the rest of the output here
if not render_result or not render_result.success:
return None
# Save HTML to output directory
html_path = os.path.join(html_dir, f"{pdf_id}_page{page_num}.html")
with open(html_path, "w") as f:
f.write(html_content)
# Convert HTML to markdown with FrontMatter and save
markdown_content = html_to_markdown_with_frontmatter(html_content)
markdown_filename = f"{pdf_id}_page{page_num}.md"
markdown_path = os.path.join(training_dir, markdown_filename)
with open(markdown_path, "w") as f:
f.write(markdown_content)
# Create soft link to PDF in training directory
pdf_link_name = f"{pdf_id}_page{page_num}.pdf"
pdf_link_path = os.path.join(training_dir, pdf_link_name)
# Remove existing link if it exists
if os.path.exists(pdf_link_path) or os.path.islink(pdf_link_path):
os.remove(pdf_link_path)
# Create relative symlink from training to pdfs directory
os.symlink(os.path.relpath(os.path.join(pdfs_dir, f"{pdf_id}_page{page_num}.pdf"), training_dir), pdf_link_path)
# Create soft link to markdown in claude_original/synthetic with new naming scheme
claude_md_link_name = f"{pdf_id}_page{page_num}_pg1_repeat1.md"
claude_md_link_path = os.path.join(claude_original_dir, claude_md_link_name)
# Remove existing link if it exists
if os.path.exists(claude_md_link_path) or os.path.islink(claude_md_link_path):
os.remove(claude_md_link_path)
# Create relative symlink from claude_original/synthetic to training directory
os.symlink(os.path.relpath(markdown_path, claude_original_dir), claude_md_link_path)
# Extract the page and save as PDF
original_pdf_path = os.path.join(pdfs_dir, f"{pdf_id}_page{page_num}_original.pdf")
if not extract_page_from_pdf(local_pdf_path, original_pdf_path, page_num):
print(f"Failed to extract page {page_num} from {local_pdf_path}")
# Create soft link in bench_data/synthetic/ directory
if playwright_pdf_path:
synthetic_link_path = os.path.join(bench_synthetic_dir, playwright_pdf_filename)
# Remove existing link if it exists
if os.path.exists(synthetic_link_path) or os.path.islink(synthetic_link_path):
os.remove(synthetic_link_path)
# Create relative symlink from bench_data/synthetic to pdfs directory
os.symlink(os.path.relpath(playwright_pdf_path, bench_synthetic_dir), synthetic_link_path)
# Generate tests from the HTML content
# Use the playwright rendered PDF path for tests
tests = generate_tests_from_html(html_content, pdf_id, page_num, random_generator, verbose_table_testing)
# Update the PDF path in all tests to use the playwright rendered PDF with the specified name prefix
for test in tests:
test["pdf"] = f"{args.name}/{playwright_pdf_filename}"
if args.introduce_text_errors > 0 and typo_records and pre_typo_html is not None:
original_markdown = html_to_markdown_with_frontmatter(pre_typo_html)
augmented_markdown = html_to_markdown_with_frontmatter(html_content)
# Remove any existing presence tests that match typo words
# (generate_tests_from_html may tag them as rare words)
typo_words = {r["typo_word"] for r in typo_records}
tests = [t for t in tests if not (t.get("type") == TestType.PRESENT.value and t.get("text") in typo_words)]
for record in typo_records:
typo_word = record["typo_word"]
test_id = f"{pdf_id}_typo_{uuid.uuid4().hex[:8]}"
test_data = {
"pdf": f"{args.name}/{playwright_pdf_filename}",
"page": 1,
"id": test_id,
"type": TestType.PRESENT.value,
"text": typo_word,
"max_diffs": 0,
}
test_obj = TextPresenceTest(
pdf=test_data["pdf"],
page=1,
id=test_id,
type=TestType.PRESENT.value,
text=typo_word,
max_diffs=0,
)
# Must FAIL against original (typo shouldn't exist before)
passed_original, _ = test_obj.run(original_markdown)
# Must PASS against augmented (typo should exist after)
passed_augmented, _ = test_obj.run(augmented_markdown)
if not passed_original and passed_augmented:
tests.append(test_data)
# Log table test stats if verbose
if verbose_table_testing:
table_tests = [t for t in tests if t["type"] == TestType.TABLE.value]
print(f"Generated {len(table_tests)} table tests for {pdf_id}, page {page_num} (passed verification)")
return {
"pdf_id": pdf_id,
"pdf_path": pdf_path,
"page_number": page_num,
"html_path": html_path,
"markdown_path": markdown_path,
"original_pdf_path": original_pdf_path,
"playwright_pdf_path": playwright_pdf_path,
"tests": tests,
"num_tests": len(tests),
}
except Exception as e:
print(f"Error processing {pdf_path}: {e}")
return None
finally:
# Clean up temp directory for this PDF
if os.path.exists(temp_pdf_dir):
subprocess.run(["rm", "-rf", temp_pdf_dir])
async def main():
# Configure logging to suppress httpx messages
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
parser = argparse.ArgumentParser(description="Convert PDFs to HTML templates and render with Playwright")
parser.add_argument("--input_list", required=True, help="Path to a file containing S3 paths or local paths to PDFs")
parser.add_argument("--output_dir", required=True, help="Directory to store extracted pages and tests")
parser.add_argument("--temp_dir", default="/tmp/mine_tables", help="Directory for temporary files")
parser.add_argument("--max_tests", type=int, default=100, help="Maximum number of tests to generate")
parser.add_argument("--seed", type=int, default=42, help="Random seed for sampling selection of PDFs")
parser.add_argument("--parallel", type=int, default=1, help="Number of parallel tasks to use")
parser.add_argument("--api_key", help="Claude API key (or set ANTHROPIC_API_KEY environment variable)")
parser.add_argument("--verbose", action="store_true", help="Enable verbose output including table test verification")
parser.add_argument("--densify", action="store_true", help="Set to ask claude to double the density of information on this page synthetically")
parser.add_argument("--jpegify", action="store_true", help="Apply JPEG compression to rendered PDFs with random quality (70-95)")
parser.add_argument(
"--introduce-text-errors", type=int, default=0, help="Introduce N intentional typos into HTML body text and generate corresponding presence tests"
)
parser.add_argument("--filter", action="store_true", help="Apply PDF filtering to remove forms, spam, and non-English content")
parser.add_argument("--name", default="synthetic", help="Name for the output JSONL file and subfolder (default: synthetic)")
args = parser.parse_args()
# Ensure output and temp directories exist
os.makedirs(args.output_dir, exist_ok=True)
os.makedirs(args.temp_dir, exist_ok=True)
# Get API key
api_key = args.api_key or os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
print("Error: API key not provided. Use --api_key or set ANTHROPIC_API_KEY environment variable.")
return
# Initialize async Claude client
client = AsyncAnthropic(api_key=api_key)
# Initialize PDF filter if enabled
pdf_filter = None
if args.filter:
pdf_filter = PdfFilter(
languages_to_keep={Language.ENGLISH, None}, # None means could not detect language, that's okay keep it, might be an OCR
apply_download_spam_check=True,
apply_form_check=True,
)
print("PDF filtering enabled")
# Reservoir sampling implementation
random_gen = random.Random(args.seed)
pdf_paths = []
if os.path.isdir(args.input_list):
pdf_paths = list(sorted(glob.glob(os.path.join(args.input_list, "*.pdf"), recursive=True)))
else:
with open(args.input_list, "r") as f:
for i, line in enumerate(tqdm(f)):
line = line.strip()
if not line:
continue
if i < 100000:
pdf_paths.append(line)
else:
# Randomly replace elements with decreasing probability
j = random_gen.randint(0, i)
if j < 100000:
pdf_paths[j] = line
print(f"Found {len(pdf_paths)} PDF paths in input list")
# Shuffle and limit to max_tests
random_gen.shuffle(pdf_paths)
pdf_paths = pdf_paths[: args.max_tests]
# Initialize the JSONL file in bench_data folder with the specified name
bench_data_dir = os.path.join(args.output_dir, "bench_data")
os.makedirs(bench_data_dir, exist_ok=True)
synthetic_json_path = os.path.join(bench_data_dir, f"{args.name}.jsonl")
# Load existing test PDFs for skip logic (resume support)
existing_test_pdfs = load_existing_test_pdfs(args)
if existing_test_pdfs:
print(f"Found {len(existing_test_pdfs)} existing PDFs with tests - will skip already processed items")
# Initialize the metadata JSONL file
metadata_dir = os.path.join(args.output_dir, "metadata")
os.makedirs(metadata_dir, exist_ok=True)
metadata_json_path = os.path.join(metadata_dir, f"{args.name}.jsonl")
# Counter for test statistics
test_counter = 0
test_types = defaultdict(int) # Automatically handles any test type
results = []
# Tracking for success/failure rates
total_attempted = 0
successful_templates = 0
failed_templates = 0
skipped_templates = 0
failure_reasons = defaultdict(int)
# Initialize an asyncio lock for file access
file_lock = asyncio.Lock()
# Process PDFs in parallel using asyncio
async def process_with_progress(pdf_info):
pdf_path = pdf_info[0]
nonlocal total_attempted, successful_templates, failed_templates
async with file_lock:
total_attempted += 1
try:
result = await process_pdf(pdf_info, args, client, pdf_filter, existing_test_pdfs)
# Handle skipped results (already processed)
if result and result.get("skipped"):
nonlocal skipped_templates
async with file_lock:
skipped_templates += 1
return result
if result and result.get("tests"):
# Append tests to synthetic.json as they're created (JSONL format)
async with file_lock:
# Append each test as a separate JSON line
with open(synthetic_json_path, "a") as f:
for test in result["tests"]:
f.write(json.dumps(test) + "\n")
# Write metadata mapping (pdf_id to source URL)
with open(metadata_json_path, "a") as f:
metadata = {"pdf_id": result["pdf_id"], "source_url": result["pdf_path"], "page_number": result["page_number"]}
f.write(json.dumps(metadata) + "\n")
# Update counters
nonlocal test_counter
test_counter += len(result["tests"])
for test in result["tests"]:
test_type = test.get("type", "unknown")
test_types[test_type] += 1
successful_templates += 1
print(f"Added {len(result['tests'])} tests from {result['pdf_id']}, total: {test_counter}")
return result
else:
async with file_lock:
failed_templates += 1
if result is None:
failure_reasons["processing_failed"] += 1
elif not result.get("tests"):
failure_reasons["no_tests_generated"] += 1
return None
except Exception as e:
print(f"Error processing {pdf_path}: {e}")
async with file_lock:
failed_templates += 1
failure_reasons["exception"] += 1
return None
# Create tasks for all PDFs
tasks = []
for i, pdf_path in enumerate(pdf_paths):
tasks.append(process_with_progress((pdf_path, i)))
# Run tasks with limited concurrency
semaphore = asyncio.Semaphore(args.parallel)
async def bounded_task(task_coro):
async with semaphore:
return await task_coro
bounded_tasks = [bounded_task(task) for task in tasks]
# Process all tasks with progress bar
pbar = tqdm(asyncio.as_completed(bounded_tasks), total=len(bounded_tasks), desc="Processing PDFs")
for coro in pbar:
result = await coro
if result:
results.append(result)
# Update progress bar with cost information
cost_input = (total_input_tokens / 1_000_000) * 3.0 # $3 per million input tokens
cost_output = (total_output_tokens / 1_000_000) * 15.0 # $15 per million output tokens
total_cost = cost_input + cost_output
pbar.set_postfix({"in_tokens": f"{total_input_tokens:,}", "out_tokens": f"{total_output_tokens:,}", "cost": f"${total_cost:.2f}"})
print(f"Generated {len(results)} HTML templates")
# Print summary of Playwright rendering results
playwright_success = sum(1 for r in results if r and r.get("playwright_pdf_path"))
print(f"Playwright PDF rendering: {playwright_success}/{len(results)} successful")
print(f"Saved {test_counter} tests to {synthetic_json_path}")
# Print summary of generated tests
print(f"Generated a total of {test_counter} tests across {len(results)} templates")
# Print test type distribution
if test_counter > 0:
print("Test type distribution:")
for test_type, count in test_types.items():
print(f" - {test_type}: {count} tests")
# Print failure rate summary
print("\n===== Template Generation Summary =====")
print(f"Total PDFs attempted: {total_attempted}")
if total_attempted > 0:
print(f"Skipped (already processed): {skipped_templates} ({skipped_templates/total_attempted*100:.1f}%)")
print(f"Successfully generated: {successful_templates} ({successful_templates/total_attempted*100:.1f}%)")
print(f"Failed: {failed_templates} ({failed_templates/total_attempted*100:.1f}%)")
else:
print("No PDFs were processed")
if failed_templates > 0 and failure_reasons:
print("\nFailure breakdown:")
for reason, count in sorted(failure_reasons.items()):
print(f" - {reason}: {count}")
# Print final Claude API cost summary
print("\nClaude Sonnet API Usage Summary:")
print(f" Total input tokens: {total_input_tokens:,}")
print(f" Total output tokens: {total_output_tokens:,}")
cost_input = (total_input_tokens / 1_000_000) * 3.0
cost_output = (total_output_tokens / 1_000_000) * 15.0
total_cost = cost_input + cost_output
print(f" Input cost: ${cost_input:.2f} ($3/MTok)")
print(f" Output cost: ${cost_output:.2f} ($15/MTok)")
print(f" Total cost: ${total_cost:.2f}")
if __name__ == "__main__":
asyncio.run(main())