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 and 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"{el.get_text()}" def convert_sub(self, el, text, parent_tags): # Always preserve sub tags as HTML return f"{el.get_text()}" 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
and