From e776ef70ae0bf76007cb660fc17139a6db7b8a9f Mon Sep 17 00:00:00 2001 From: Matteo Meucci Date: Fri, 14 Nov 2025 14:57:13 +0100 Subject: [PATCH] Add files via upload --- PDFGenerator/PDFGenFinal.py | 732 ++++++++++++++++++++++++++++++++++++ PDFGenerator/config.txt | 21 ++ PDFGenerator/header-bg.jpg | Bin 0 -> 10865 bytes 3 files changed, 753 insertions(+) create mode 100644 PDFGenerator/PDFGenFinal.py create mode 100644 PDFGenerator/config.txt create mode 100644 PDFGenerator/header-bg.jpg diff --git a/PDFGenerator/PDFGenFinal.py b/PDFGenerator/PDFGenFinal.py new file mode 100644 index 0000000..76a0f8e --- /dev/null +++ b/PDFGenerator/PDFGenFinal.py @@ -0,0 +1,732 @@ + +#!/usr/bin/env python3 +import argparse +import re +import subprocess +from datetime import datetime +from pathlib import Path +from urllib.parse import urljoin, urlparse + +import requests +from markdown import markdown +from weasyprint import HTML + +VALIDATE_STRUCTURE = False + + +# ------------------ Configuration Management ------------------ # + +def load_config(config_path: Path) -> dict: + """Load configuration from a text file.""" + config = { + 'PROJECT_NAME': 'Document', + 'VERSION': 'Version 1.0', + 'TOC_PATH': 'ToC.md', + 'OUTPUT_FILE': 'output.pdf', + 'COVER_IMAGE': '', + 'HEADER_IMAGE': '' + } + + if not config_path.exists(): + print(f"⚠ Config file not found: {config_path}") + return config + + print(f"Loading configuration from: {config_path}") + + with open(config_path, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + # Skip comments and empty lines + if not line or line.startswith('#'): + continue + + # Parse KEY=VALUE + if '=' in line: + key, value = line.split('=', 1) + key = key.strip() + value = value.strip() + + if key in config: + config[key] = value + print(f" {key} = {value}") + + return config + + +# ------------------ Utility functions ------------------ # + +def sanitize_heading(text: str) -> str: + """Create a safe HTML id from heading text.""" + return re.sub(r'[^a-zA-Z0-9_-]', '', text.replace(' ', '_')) + + +def get_git_info(): + """Return (branch, short_commit) or ('unknown', 'unknown').""" + try: + commit = subprocess.check_output( + ["git", "rev-parse", "--short", "HEAD"] + ).decode().strip() + branch = subprocess.check_output( + ["git", "rev-parse", "--abbrev-ref", "HEAD"] + ).decode().strip() + return branch, commit + except Exception: + return "unknown", "unknown" + + +def transform_special_blockquotes(md_text: str) -> str: + """Convert custom NOTE/COMMENT blockquotes in markdown to styled HTML blockquotes.""" + md_text = re.sub( + r'(?m)^> NOTE:\s*(.*)', + r'
Note: \1
', + md_text + ) + md_text = re.sub( + r'(?m)^> COMMENT:\s*(.*)', + r'
Comment: \1
', + md_text + ) + return md_text + + +def validate_markdown(file_path: Path, html: str): + """Optionally ensure markdown has at least one h1, etc.""" + headings = re.findall(r'(.*?)', html) + + if VALIDATE_STRUCTURE: + if not any(level == '1' for level, _ in headings): + raise ValueError(f"Validation failed: No

heading found in {file_path}") + + return headings + + +def resolve_image_paths(html: str, base_path: Path, repo_root: Path = None) -> str: + """ + Convert to absolute file:// paths for WeasyPrint (local files). + Improved version with better error handling and path resolution. + Handles /Document/images/ paths relative to repository root. + """ + def repl(match): + # Extract the full img tag and src attribute + full_tag = match.group(0) + src = match.group(1) + + # Skip if already absolute URL or file:// + if src.startswith(('http://', 'https://', 'file://', 'data:')): + return full_tag + + # Try multiple resolution strategies + possible_paths = [] + + # Strategy 1: If path starts with /Document/, resolve from repo root + if src.startswith('/Document/') and repo_root: + # Remove leading slash and resolve from repo root + rel_path = src.lstrip('/') + possible_paths.append(repo_root / rel_path) + + # Strategy 2: Relative to markdown file + possible_paths.append(base_path / src) + + # Strategy 3: One level up from markdown file + possible_paths.append(base_path.parent / src) + + # Strategy 4: Two levels up (for nested content) + possible_paths.append(base_path.parent.parent / src) + + # Strategy 5: Absolute path as-is + if not src.startswith('/'): + possible_paths.append(Path(src)) + + # Strategy 6: Remove leading slashes or dots + cleaned_src = src.lstrip('./') + possible_paths.append(base_path / cleaned_src) + + # Strategy 7: If starts with /, try from repo root + if src.startswith('/') and repo_root: + possible_paths.append(repo_root / src.lstrip('/')) + + # Find first existing path + abs_path = None + for path in possible_paths: + try: + resolved = path.resolve() + if resolved.exists(): + abs_path = resolved + break + except (OSError, ValueError): + continue + + if abs_path is None: + print(f"⚠ Warning: Image file not found: {src}") + print(f" Searched from: {base_path}") + if repo_root: + print(f" Repository root: {repo_root}") + print(f" Tried {len(possible_paths)} possible paths") + # Return original tag but with a placeholder style + return f'
⚠ Missing image: {src}
' + else: + print(f"✓ Embedding image: {src} → {abs_path}") + # Return img tag with file:// URL and proper styling + return f'' + + return re.sub(r']*src="([^"]+)"[^>]*>', repl, html) + + +def parse_toc_file(toc_path: Path): + print(f"\nParsing ToC file: {toc_path}") + toc_content = toc_path.read_text(encoding="utf-8") + + link_pattern = r'\[([^\]]+)\]\(([^)]+\.md)\)' + matches = re.findall(link_pattern, toc_content) + + file_refs = [] + toc_dir = toc_path.parent + + # repo locale clonato accanto allo script + REPO_ROOT = (Path(__file__).parent / "www-project-ai-testing-guide").resolve() + GITHUB_PREFIX = "https://github.com/OWASP/www-project-ai-testing-guide/blob/main/" + + for title, href in matches: + if href.startswith(GITHUB_PREFIX): + rel_path = href[len(GITHUB_PREFIX):] + abs_path = (REPO_ROOT / rel_path).resolve() + else: + abs_path = (toc_dir / href).resolve() + + if abs_path.exists(): + print(f" ✓ Local file: {href} → {abs_path}") + file_refs.append((href, abs_path)) + else: + print(f" ⚠ Missing file: {href} → {abs_path}") + + print(f"Total entries found: {len(file_refs)}") + return file_refs + + +def scan_directory(input_dir: Path): + """Fallback: scan a directory for .md files (not used if you work with ToC.md).""" + print(f"\nScanning directory: {input_dir}") + markdown_files = sorted(input_dir.rglob('*.md'), key=lambda p: str(p)) + print(f"Total files found: {len(markdown_files)}") + return [(str(p), p) for p in markdown_files] + + +def rewrite_links_to_anchors(html: str, link_map: dict[str, str]) -> str: + """Replace href="URL" with href="#anchor" when URL is present in link_map.""" + def repl(match): + href = match.group(1) + if href in link_map: + return f'href="#{link_map[href]}"' + return match.group(0) + + return re.sub(r'href="([^"]+)"', repl, html) + + +# ------------------ Main PDF generation ------------------ # + +def generate_pdf(input_path: Path, output_file: Path, project_name: str = "Document", + version: str = "Version 1.0", cover_image_path: str = "", + header_image_path: str = ""): + print(">>> PDF Generator - Final Version with Config Support") + print(f">>> Running from: {__file__}") + print(f">>> Project: {project_name}") + print(f">>> Version: {version}") + + # Determine repository root + # Try to find www-project-ai-testing-guide directory + REPO_ROOT = None + if input_path.is_file(): + # Start from ToC file's directory and search upwards + current = input_path.parent.resolve() + else: + current = input_path.resolve() + + # Search for repository root (contains Document folder) + while current != current.parent: + if (current / "Document").exists(): + REPO_ROOT = current + print(f">>> Repository root detected: {REPO_ROOT}") + break + current = current.parent + + if REPO_ROOT is None: + # Fallback: check if script is in repo + script_parent = Path(__file__).parent / "www-project-ai-testing-guide" + if script_parent.exists(): + REPO_ROOT = script_parent.resolve() + print(f">>> Repository root (from script): {REPO_ROOT}") + + # 1) Read ToC or scan directory + toc_html = None + + if input_path.is_file(): + print("Mode: ToC file") + toc_md = input_path.read_text(encoding="utf-8") + toc_md = transform_special_blockquotes(toc_md) + toc_html = markdown(toc_md, extensions=['extra', 'nl2br', 'sane_lists', 'attr_list']) + file_entries = parse_toc_file(input_path) + elif input_path.is_dir(): + print("Mode: Directory scan") + file_entries = scan_directory(input_path) + toc_html = "

Table of Contents

" + else: + raise ValueError(f"Input path does not exist: {input_path}") + + if not file_entries: + raise ValueError("No markdown files found to process") + + # 2) Build link map: href -> doc anchor id (doc1, doc2, ...) + link_map: dict[str, str] = {} + for idx, (href, _) in enumerate(file_entries): + link_map[href] = f"doc{idx + 1}" + + # rewrite ToC links to internal anchors + if toc_html is not None: + toc_html = rewrite_links_to_anchors(toc_html, link_map) + + content_blocks: list[str] = [] + + print(f"\nProcessing {len(file_entries)} files...") + for href, ref in file_entries: + doc_anchor = link_map[href] + print(f"\n Processing [{doc_anchor}]: {ref}") + + # ref è SEMPRE un Path locale + raw_md = ref.read_text(encoding="utf-8") + base_path = ref.parent + + # NOTE/COMMENT + raw_md = transform_special_blockquotes(raw_md) + + # Markdown -> HTML + html = markdown(raw_md, extensions=['extra', 'nl2br', 'sane_lists', 'attr_list']) + + # Immagini locali -> file:// (improved resolution) + html = resolve_image_paths(html, base_path, REPO_ROOT) + + # Rewrite links to internal anchors + html = rewrite_links_to_anchors(html, link_map) + + # Add id to headings + headings = validate_markdown(ref, html) + for level, heading in headings: + anchor = sanitize_heading(heading) + html = html.replace( + f"{heading}", + f"{heading}" + ) + + # Avvolgi ogni documento in
+ html = f'
{html}
' + content_blocks.append(html) + + + # Cover image + header logo + if cover_image_path: + cover_path = Path(cover_image_path).resolve() + if not cover_path.exists(): + # Try relative to script + cover_path = (Path(__file__).parent / cover_image_path).resolve() + else: + cover_path = (Path(__file__).parent / "Cover.png").resolve() + + if not cover_path.exists(): + print(f"⚠ Cover image not found at {cover_path}, generating PDF without image cover.") + cover_img_html = "" + else: + cover_src = f"file://{cover_path}" + cover_img_html = f'' + print(f"✓ Cover image found: {cover_path}") + + if header_image_path: + header_bg_path = Path(header_image_path).resolve() + if not header_bg_path.exists(): + # Try relative to script + header_bg_path = (Path(__file__).parent / header_image_path).resolve() + else: + header_bg_path = (Path(__file__).parent / "header-bg.png").resolve() + + if not header_bg_path.exists(): + print(f"⚠ Header image not found at {header_bg_path}, generating header without logo.") + header_content = '""' + else: + header_bg_url = f"file://{header_bg_path}" + header_content = f'url("{header_bg_url}")' + print(f"✓ Header image found: {header_bg_path}") + + # CSS - FINAL VERSION with all fixes + css = """ +@page {{ + size: A4; + margin: 2.5cm 2.5cm 2cm 2.5cm; + + @top-left {{ + content: "{project_name}"; + font-size: 10pt; + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + color: #103595; + font-weight: 500; + vertical-align: bottom; + padding: 0.7cm 0.0cm; /* Padding for badge effect */ + border-radius: 0px; /* Rounded corners */ + }} + + @top-center {{ + content: ""; + }} + + @top-right {{ + content: {header_content}; + vertical-align: bottom; + padding-bottom: 0.3cm; + }} + + @bottom-left {{ + content: "{version}"; + font-size: 8pt; + color: #666; + vertical-align: top; + padding-top: 0.2cm; + }} + + @bottom-right {{ + content: "Page " counter(page) " of " counter(pages); + font-size: 8pt; + color: #666; + vertical-align: top; + padding-top: 0.2cm; + }} +}} + +@page cover {{ + size: A4; + margin: 0 0 -0.5cm 0; + background: none; + + @top-left {{ content: none; }} + @top-center {{ content: none; }} + @top-right {{ content: none; }} + @bottom-left {{ content: none; }} + @bottom-center{{ content: none; }} + @bottom-right {{ content: none; }} +}} + +body {{ + font-family: 'Georgia', 'Times New Roman', serif; + font-size: 11pt; + line-height: 1.6; + color: #333; + margin: 0; +}} + +.cover {{ + page: cover; + width: 100%; + height: 100vh; + page-break-after: always; + display: flex; + align-items: center; + justify-content: center; +}} + +.cover-image {{ + width: 100%; + height: 100%; + object-fit: contain; + object-position: center center; + margin: 0; + padding: 0; +}} + +.main {{ + margin: 0 auto; + max-width: 75ch; +}} + +.toc {{ + page-break-after: always; +}} + +/* Professional color scheme: Deep Blue for h1, Medium Blue for h2, Dark Gray for h3 */ + +h1, h2, h3, h4, h5, h6 {{ + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-weight: 600; + line-height: 1.3; + page-break-after: avoid; +}} + +h1 {{ + font-size: 1.8em; /* Reduced from 2.4em */ + color: #1e3a5f; /* Deep Blue */ + margin-top: 2em; + margin-bottom: 0.6em; + padding-bottom: 0.3em; + border-bottom: 2px solid #2c5aa0; + page-break-before: always; + page-break-after: avoid; + letter-spacing: -0.02em; +}} + +h2 {{ + font-size: 1.4em; /* Reduced from 1.8em */ + color: #2c5aa0; /* Medium Blue */ + margin-top: 1.5em; + margin-bottom: 0.5em; + page-break-after: avoid; + letter-spacing: -0.01em; +}} + +h3 {{ + font-size: 1.15em; /* Reduced from 1.4em */ + color: #34495e; /* Dark Gray */ + margin-top: 1.2em; + margin-bottom: 0.4em; + page-break-after: avoid; + font-weight: 600; +}} + +h4 {{ + font-size: 1.05em; + color: #555; + margin-top: 1em; + margin-bottom: 0.3em; + page-break-after: avoid; + font-weight: 600; +}} + +p {{ + text-align: justify; + hyphens: auto; + margin: 0.8em 0; +}} + +p, li, table, blockquote {{ + orphans: 2; + widows: 2; +}} + +/* Image styling */ +img {{ + display: block; + margin: 1.5em auto; + max-width: 100%; + height: auto; + border: 1px solid #e0e0e0; + padding: 0.5em; + background: #fafafa; +}} + +.missing-image {{ + display: block; + margin: 1.5em auto; + padding: 1em; + background: #fff3cd; + border: 1px solid #ffc107; + color: #856404; + text-align: center; + font-style: italic; +}} + +/* Lists */ +ul, ol {{ + margin: 0.8em 0; + padding-left: 2em; +}} + +li {{ + margin: 0.3em 0; +}} + +/* Code blocks */ +code {{ + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 0.85em; + background-color: #f5f5f5; + padding: 0.2em 0.4em; + border-radius: 3px; + word-wrap: break-word; +}} + +pre {{ + background-color: #f5f5f5; + border: 1px solid #ddd; + border-left: 3px solid #2c5aa0; + padding: 0.8em; + overflow-x: auto; + overflow-wrap: break-word; + word-wrap: break-word; + white-space: pre-wrap; + margin: 1em 0; + page-break-inside: avoid; + font-size: 0.85em; + line-height: 1.4; +}} + +pre code {{ + background: none; + padding: 0; + word-wrap: break-word; + white-space: pre-wrap; +}} + +/* Tables */ +table {{ + width: 100%; + border-collapse: collapse; + margin: 1.5em 0; + font-size: 10pt; + table-layout: auto; +}} + +th {{ + background-color: #2c5aa0; + color: white; + font-weight: 600; + border: 1px solid #1e3a5f; + padding: 8px 10px; + text-align: left; + vertical-align: top; +}} + +td {{ + border: 1px solid #ddd; + padding: 6px 10px; + text-align: left; + vertical-align: top; + word-wrap: break-word; + white-space: normal; +}} + +tr:nth-child(even) {{ + background-color: #f9f9f9; +}} + +/* Blockquotes */ +blockquote {{ + background-color: #f5f5f5; + border-left: 4px solid #2c5aa0; + padding: 0.8em 1.2em; + margin: 1em 0; + font-size: 0.95em; + font-style: italic; +}} + +blockquote.note {{ + background-color: #fff8dc; + border-left: 4px solid #f0ad4e; +}} + +blockquote.comment {{ + background-color: #e7f3ff; + border-left: 4px solid #2c5aa0; +}} + +/* Links */ +a {{ + color: #2c5aa0; + text-decoration: none; +}} + +a:hover {{ + text-decoration: underline; +}} + +/* Horizontal rules */ +hr {{ + border: none; + border-top: 1px solid #ddd; + margin: 2em 0; +}} +""".format(header_content=header_content, project_name=project_name, version=version) + + combined_html = """ + + + + + + +
+ {cover_img} +
+
+
+ {toc_html} +
+ {content} +
+ + +""".format( + css=css, + cover_img=cover_img_html, + toc_html=toc_html or "", + content="".join(content_blocks), + ) + + output_file.parent.mkdir(parents=True, exist_ok=True) + print(f"\n{'='*60}") + print(f"Generating PDF: {output_file}") + print(f"{'='*60}") + + HTML(string=combined_html, base_url=str(Path(__file__).parent)).write_pdf(str(output_file)) + + print(f"\n✓ PDF generated successfully!") + print(f" Output: {output_file}") + print(f" Size: {output_file.stat().st_size / 1024:.1f} KB") + + +# ------------------ CLI entrypoint ------------------ # + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Generate PDF from markdown files with optional configuration file support." + ) + parser.add_argument( + "--config", + "-c", + type=Path, + help="Path to configuration file (config.txt)", + ) + parser.add_argument( + "input_path", + type=Path, + nargs='?', + help="Path to ToC markdown file OR root directory containing markdown files (overrides config)", + ) + parser.add_argument( + "output_file", + type=Path, + nargs='?', + help="Path to output PDF file (overrides config)", + ) + + args = parser.parse_args() + + # Load configuration if provided + if args.config: + config = load_config(args.config) + + # Use command line arguments if provided, otherwise use config + input_path = args.input_path if args.input_path else Path(config['TOC_PATH']) + output_file = args.output_file if args.output_file else Path(config['OUTPUT_FILE']) + project_name = config['PROJECT_NAME'] + version = config['VERSION'] + cover_image = config['COVER_IMAGE'] + header_image = config['HEADER_IMAGE'] + else: + # Traditional mode: require both arguments + if not args.input_path or not args.output_file: + parser.error("input_path and output_file are required when not using --config") + + input_path = args.input_path + output_file = args.output_file + project_name = "Document" + version = "Version 1.0" + cover_image = "" + header_image = "" + + generate_pdf(input_path, output_file, project_name, version, cover_image, header_image) \ No newline at end of file diff --git a/PDFGenerator/config.txt b/PDFGenerator/config.txt new file mode 100644 index 0000000..8662c54 --- /dev/null +++ b/PDFGenerator/config.txt @@ -0,0 +1,21 @@ +# PDF Generator Configuration File +# Lines starting with # are comments and will be ignored +# Format: KEY=VALUE (no spaces around =) + +# Project name (appears in header on the left) +PROJECT_NAME=OWASP AI Testing Guide + +# Version number (appears in footer on the left) +VERSION=Version 0.9 + +# Path to Table of Contents markdown file +TOC_PATH=md/ToC.md + +# Output PDF file name +OUTPUT_FILE=OWASP-AI-Testing-Guide-v0.9.pdf + +# Path to cover image (optional, leave empty if not needed) +COVER_IMAGE=Cover.png + +# Path to header background/logo image (optional, leave empty if not needed) +HEADER_IMAGE=header-bg.jpg diff --git a/PDFGenerator/header-bg.jpg b/PDFGenerator/header-bg.jpg new file mode 100644 index 0000000000000000000000000000000000000000..001cd925217680ae033aafd1ed66103a9c464391 GIT binary patch literal 10865 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%0DMS7Lgm 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 bWO|iFWI0dfXg2Oz64Si#