"""Convert every PDF in examples/ into a static HTML/CSS site under output/. Shells out to poppler-utils (pdftocairo, pdftotext, pdfinfo). No third-party deps. Usage: python main.py (or: uv run python main.py) """ from __future__ import annotations import html import re import shutil import subprocess import sys from pathlib import Path ROOT = Path(__file__).parent EXAMPLES_DIR = ROOT / "examples" OUTPUT_DIR = ROOT / "output" DPI_FULL = 150 DPI_THUMB = 30 JPEG_QUALITY_FULL = 85 JPEG_QUALITY_THUMB = 80 REQUIRED_BINARIES = ("pdftocairo", "pdftotext", "pdfinfo") def main() -> int: missing = [b for b in REQUIRED_BINARIES if shutil.which(b) is None] if missing: print(f"error: missing poppler-utils binaries: {', '.join(missing)}", file=sys.stderr) print("install with: sudo apt install poppler-utils", file=sys.stderr) return 1 if not EXAMPLES_DIR.is_dir(): print(f"error: {EXAMPLES_DIR} does not exist", file=sys.stderr) return 1 pdfs = sorted(EXAMPLES_DIR.glob("*.pdf")) if not pdfs: print(f"error: no PDFs found in {EXAMPLES_DIR}", file=sys.stderr) return 1 OUTPUT_DIR.mkdir(exist_ok=True) write_shared_css() issues = [] for pdf in pdfs: print(f"converting {pdf.name} ...") issues.append(convert_pdf(pdf)) write_root_index(issues) print(f"\ndone. open {OUTPUT_DIR / 'index.html'} in a browser.") return 0 def convert_pdf(pdf_path: Path) -> dict: stem = pdf_path.stem out = OUTPUT_DIR / stem reset_dir(out) page_count = pdf_page_count(pdf_path) page_texts = extract_text_per_page(pdf_path, page_count) for n in range(1, page_count + 1): render_jpeg(pdf_path, out / f"page-{n:02d}", n, DPI_FULL, JPEG_QUALITY_FULL) render_jpeg(pdf_path, out / f"thumb-{n:02d}", n, DPI_THUMB, JPEG_QUALITY_THUMB) write_page_html(out, stem, n, page_count, page_texts[n - 1]) write_issue_index(out, stem, page_count) return {"stem": stem, "title": stem, "pages": page_count} def reset_dir(path: Path) -> None: if path.exists(): shutil.rmtree(path) path.mkdir(parents=True) def pdf_page_count(pdf_path: Path) -> int: out = subprocess.run( ["pdfinfo", str(pdf_path)], check=True, capture_output=True, text=True ).stdout m = re.search(r"^Pages:\s+(\d+)", out, re.MULTILINE) if not m: raise RuntimeError(f"could not parse page count from pdfinfo for {pdf_path}") return int(m.group(1)) def extract_text_per_page(pdf_path: Path, page_count: int) -> list[str]: """Return a list of page texts. pdftotext separates pages with form-feed (\\f).""" out = subprocess.run( ["pdftotext", "-layout", str(pdf_path), "-"], check=True, capture_output=True, text=True, ).stdout pages = out.split("\f") # pdftotext appends a trailing form-feed → one extra empty element; trim it. if pages and pages[-1] == "": pages.pop() # Pad/truncate so the list matches page_count exactly (defensive). while len(pages) < page_count: pages.append("") return pages[:page_count] def render_jpeg(pdf_path: Path, out_stem: Path, page: int, dpi: int, quality: int) -> None: """pdftocairo always appends -.jpg unless -singlefile is used. We render one page at a time with -singlefile so the output filename is exact. """ subprocess.run( [ "pdftocairo", "-jpeg", "-jpegopt", f"quality={quality}", "-r", str(dpi), "-f", str(page), "-l", str(page), "-singlefile", str(pdf_path), str(out_stem), # pdftocairo appends .jpg automatically ], check=True, capture_output=True, ) # ---------- HTML emission ---------- def write_shared_css() -> None: (OUTPUT_DIR / "styles.css").write_text(SHARED_CSS, encoding="utf-8") def write_root_index(issues: list[dict]) -> None: cards = "\n".join( f""" Cover of {html.escape(i['title'])}

{html.escape(i['title'])}

{i['pages']} pages

""" for i in issues ) page = PAGE_SHELL.format( title="NETgazet archive", css_href="styles.css", body=f"""

NETgazet archive

Static HTML rendering of the available editions.

{cards}
""", ) (OUTPUT_DIR / "index.html").write_text(page, encoding="utf-8") def write_issue_index(out: Path, stem: str, page_count: int) -> None: thumbs = "\n".join( f""" Page {n} Page {n} """ for n in range(1, page_count + 1) ) page = PAGE_SHELL.format( title=html.escape(stem), css_href="../styles.css", body=f"""

← all editions

{html.escape(stem)}

{page_count} pages — tap a thumbnail to read.

{thumbs}
""", ) (out / "index.html").write_text(page, encoding="utf-8") def write_page_html(out: Path, stem: str, n: int, total: int, text: str) -> None: prev_link = ( f'' if n > 1 else '← Page' ) next_link = ( f'' if n < total else 'Page →' ) text_block = ( f"""
Show page text
{html.escape(text)}
""" if text.strip() else "" ) body = f"""

← {html.escape(stem)} · all editions

Page {n} of {total}

Page {n} of {html.escape(stem)}
{text_block}
""" page = PAGE_SHELL.format( title=f"{html.escape(stem)} — page {n}", css_href="../styles.css", body=body, ) (out / f"page-{n:02d}.html").write_text(page, encoding="utf-8") # ---------- Templates ---------- PAGE_SHELL = """ {title} {body} """ SHARED_CSS = """:root { --fg: #1a1a1a; --muted: #666; --bg: #fafafa; --card: #fff; --border: #e2e2e2; --accent: #1a4480; } * { box-sizing: border-box; } html, body { margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; color: var(--fg); background: var(--bg); line-height: 1.45; max-width: 1000px; margin: 0 auto; padding: 1rem; } img { max-width: 100%; height: auto; display: block; } a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; } header { margin-bottom: 1.5rem; } header h1 { margin: 0.25rem 0; font-size: 1.5rem; } header p { margin: 0.25rem 0; color: var(--muted); } .of { color: var(--muted); font-weight: normal; font-size: 0.85em; } .card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1.25rem; } .card { display: block; background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 0.75rem; color: inherit; } .card:hover { border-color: var(--accent); text-decoration: none; } .card img { border: 1px solid var(--border); margin-bottom: 0.5rem; } .card h2 { margin: 0.25rem 0; font-size: 1.05rem; } .card p { margin: 0; color: var(--muted); font-size: 0.9rem; } .thumb-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 1rem; } .thumb { display: block; text-align: center; color: inherit; } .thumb img { border: 1px solid var(--border); border-radius: 3px; margin: 0 auto 0.25rem; } .thumb:hover img { border-color: var(--accent); } .thumb span { font-size: 0.85rem; color: var(--muted); } .pager { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: space-between; align-items: center; margin: 1rem 0; padding: 0.5rem 0; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); font-size: 0.95rem; } .pager .nav-up { font-weight: 600; } .pager .nav-disabled { color: #bbb; } .pager-bottom { margin-top: 1rem; } .page { margin: 0; } .page img { border: 1px solid var(--border); box-shadow: 0 2px 8px rgba(0,0,0,0.06); margin: 0 auto; } .page-text { margin-top: 1rem; background: var(--card); border: 1px solid var(--border); border-radius: 4px; padding: 0.5rem 0.75rem; } .page-text summary { cursor: pointer; color: var(--accent); font-size: 0.95rem; } .page-text pre { white-space: pre-wrap; word-wrap: break-word; font-size: 0.85rem; line-height: 1.4; margin: 0.75rem 0 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } @media (max-width: 480px) { body { padding: 0.5rem; } header h1 { font-size: 1.25rem; } .pager { font-size: 0.85rem; } } """ if __name__ == "__main__": sys.exit(main())