Build Site
Unified site generator: exports data.json, builds all HTML pages (scholars, dictionary, marginalia, bibliography, docs, code, about).
1"""Unified site builder: generates all static pages from SQLite. 2 3Replaces build_scholar_profiles.py and export_showcase_data.py. 4Generates: 5 - site/index.html (marginalia gallery, updated nav) 6 - site/data.json (gallery data with confidence flags) 7 - site/scholars.html (scholars overview, DB-driven) 8 - site/scholar/*.html (individual scholar pages) 9 - site/dictionary/index.html (dictionary landing) 10 - site/dictionary/*.html (individual term pages) 11 - site/marginalia/*.html (individual folio detail pages) 12 - site/about.html (about page) 13""" 14 15import sqlite3 16import json 17import re 18import os 19from pathlib import Path 20from html import escape 21 22BASE_DIR = Path(__file__).resolve().parent.parent 23DB_PATH = BASE_DIR / "db" / "hp.db" 24SITE_DIR = BASE_DIR / "site" 25SUMMARIES_PATH = BASE_DIR / "scholars" / "summaries.json" 26 27# ============================================================ 28# Shared HTML templates 29# ============================================================ 30 31def slugify(text): 32 s = text.lower().strip() 33 s = re.sub(r'[^\w\s-]', '', s) 34 s = re.sub(r'[\s_]+', '-', s) 35 s = re.sub(r'-+', '-', s) 36 return s.strip('-') 37 38 39def nav_html(active='', prefix=''): 40 """Generate navigation bar. prefix is '' for root, '../' for subdirectories.""" 41 links = [ 42 (f'{prefix}index.html', 'Home', 'home'), 43 (f'{prefix}marginalia/index.html', 'Marginalia', 'marginalia'), 44 (f'{prefix}scholars.html', 'Scholars', 'scholars'), 45 (f'{prefix}bibliography.html', 'Bibliography', 'bibliography'), 46 (f'{prefix}dictionary/index.html', 'Dictionary', 'dictionary'), 47 (f'{prefix}docs/index.html', 'Docs', 'docs'), 48 (f'{prefix}code/index.html', 'Code', 'code'), 49 (f'{prefix}the-book.html', 'The Book', 'thebook'), 50 (f'{prefix}timeline.html', 'Timeline', 'timeline'), 51 (f'{prefix}woodcuts/index.html', 'Woodcuts', 'woodcuts'), 52 (f'{prefix}manuscripts/index.html', 'Manuscripts', 'manuscripts'), 53 (f'{prefix}digital-edition.html', 'Editions', 'edition'), 54 (f'{prefix}russell-alchemical-hands.html', 'Alchemical Hands', 'russell'), 55 (f'{prefix}concordance/index.html', 'Concordance', 'concordance'), 56 (f'{prefix}jonson/index.html', 'Ben Jonson', 'jonson'), 57 (f'{prefix}digby/index.html', 'Digby', 'digby'), 58 (f'{prefix}about.html', 'About', 'about'), 59 ] 60 items = [] 61 for href, label, key in links: 62 cls = ' class="active"' if key == active else '' 63 items.append(f'<a href="{href}"{cls}>{label}</a>') 64 return f'<nav class="site-nav">{"".join(items)}</nav>' 65 66 67def review_badge_html(needs_review, source_method=None): 68 if not needs_review: 69 return '' 70 method = f' ({source_method})' if source_method else '' 71 return f'<span class="review-badge">Unreviewed{method}</span>' 72 73 74def confidence_badge_html(confidence): 75 if not confidence: 76 return '' 77 cls = f'confidence-{confidence.lower()}' 78 return f'<span class="confidence-badge {cls}">{confidence}</span>' 79 80 81def page_shell(title, body, active_nav='', extra_css='', extra_js='', depth=0): 82 """Generate full HTML page. depth=0 for site root, depth=1 for subdirectories.""" 83 prefix = '../' * depth if depth > 0 else '' 84 return f"""<!DOCTYPE html> 85<html lang="en"> 86<head> 87 <meta charset="UTF-8"> 88 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 89 <title>{escape(title)} - Alchemical Hands in the Hypnerotomachia Poliphili</title> 90 <meta name="description" content="Alchemical hands in the marginalia of the Hypnerotomachia Poliphili (Venice, 1499). A digital scholarship platform based on James Russell's PhD thesis."> 91 <link rel="stylesheet" href="{prefix}style.css"> 92 <link rel="stylesheet" href="{prefix}scholars.css"> 93 <link rel="stylesheet" href="{prefix}components.css"> 94 {extra_css} 95</head> 96<body> 97 <header> 98 <div class="header-content"> 99 <h1><a href="{prefix}index.html" style="color:inherit;text-decoration:none">Alchemical Hands in the <em>Hypnerotomachia Poliphili</em></a></h1> 100 <p class="subtitle">Marginalia, Scholarship & Reception</p> 101 {nav_html(active_nav, prefix)} 102 </div> 103 </header> 104 <main> 105{body} 106 </main> 107 <footer> 108 <div class="footer-content"> 109 <div class="footer-section"> 110 <h4>About This Project</h4> 111 <p>A digital scholarship platform exploring the alchemical 112 annotations and wider marginalia of the 1499 <em>Hypnerotomachia 113 Poliphili</em>, based on James Russell's PhD thesis (Durham, 2014).</p> 114 </div> 115 <div class="footer-section"> 116 <h4>Data Provenance</h4> 117 <p>Content marked with <span class="review-badge" style="font-size:0.7rem">Unreviewed</span> 118 has been generated with LLM assistance and has not been 119 verified by a human expert.</p> 120 </div> 121 </div> 122 </footer> 123 {extra_js} 124</body> 125</html>""" 126 127 128# ============================================================ 129# Topic badge helpers 130# ============================================================ 131 132TOPIC_LABELS = { 133 'authorship': 'Authorship', 134 'architecture_gardens': 'Architecture & Gardens', 135 'text_image': 'Text & Image', 136 'reception': 'Reception', 137 'dream_religion': 'Dream & Religion', 138 'material_bibliographic': 'Material & Bibliographic', 139} 140 141 142def topic_badges_html(topics_str): 143 if not topics_str: 144 return '' 145 badges = [] 146 for t in topics_str.split(','): 147 t = t.strip() 148 label = TOPIC_LABELS.get(t, t.replace('_', ' ').title()) 149 badges.append(f'<span class="topic-badge topic-{t}">{label}</span>') 150 return ' '.join(badges) 151 152 153# ============================================================ 154# Data export: data.json 155# ============================================================ 156 157def export_data_json(conn): 158 """Export marginalia gallery data with confidence flags.""" 159 cur = conn.cursor() 160 cur.execute(""" 161 SELECT 162 a.id, a.thesis_page, a.signature_ref, m.shelfmark, 163 m.institution, m.city, dr.context_text, a.annotation_text, 164 a.thesis_chapter, i.filename, COALESCE(i.web_path, i.relative_path), 165 i.folio_number, i.side, mat.confidence, 166 sm.quire, sm.leaf_in_quire, 167 mat.needs_review as match_needs_review, 168 h.hand_label, h.attribution, h.is_alchemist, 169 a.annotation_type 170 FROM matches mat 171 JOIN annotations a ON mat.ref_id = a.id 172 JOIN images i ON mat.image_id = i.id 173 JOIN manuscripts m ON i.manuscript_id = m.id 174 LEFT JOIN dissertation_refs dr ON a.id = dr.id 175 LEFT JOIN signature_map sm ON LOWER(a.signature_ref) = LOWER(sm.signature) 176 LEFT JOIN annotator_hands h ON a.hand_id = h.id 177 WHERE i.page_type = 'PAGE' 178 GROUP BY a.signature_ref, i.filename 179 ORDER BY COALESCE(sm.folio_number, 999), a.thesis_page 180 """) 181 182 # Load folio descriptions for alchemist annotations 183 folio_descs = {} 184 try: 185 cur2 = conn.cursor() 186 cur2.execute(""" 187 SELECT signature_ref, manuscript_shelfmark, hand_label, 188 title, description, alchemical_element, alchemical_process, 189 alchemical_framework, russell_page_ref 190 FROM folio_descriptions 191 """) 192 for fd in cur2.fetchall(): 193 key = (fd[0], fd[1]) 194 folio_descs[key] = { 195 'desc_title': fd[3], 'desc_text': fd[4], 196 'alch_element': fd[5], 'alch_process': fd[6], 197 'alch_framework': fd[7], 'russell_pages': fd[8], 198 } 199 except: 200 pass # Table may not exist yet 201 202 entries = [] 203 sigs = set() 204 for row in cur.fetchall(): 205 sig = row[2] 206 ms = row[3] 207 entry = { 208 'ref_id': row[0], 'thesis_page': row[1], 209 'signature': sig, 'manuscript': ms, 210 'institution': row[4], 'city': row[5], 211 'context': (row[6] or '')[:600], 212 'marginal_text': row[7], 'chapter': row[8], 213 'image_file': row[9], 214 'image_path': row[10], 215 'folio_number': row[11], 'side': row[12], 216 'confidence': row[13] or 'PROVISIONAL', 217 'quire': row[14], 'leaf_in_quire': row[15], 218 'needs_review': bool(row[16]), 219 'hand_label': row[17], 'hand_attribution': row[18], 220 'is_alchemist': bool(row[19]) if row[19] is not None else False, 221 'annotation_type': row[20], 222 } 223 # Add folio description if available 224 fd = folio_descs.get((sig, ms)) or folio_descs.get((sig, None)) 225 if fd: 226 entry['desc_title'] = fd['desc_title'] 227 entry['desc_text'] = fd['desc_text'] 228 entry['alch_element'] = fd['alch_element'] 229 230 # Generate a brief description for every entry 231 parts = [] 232 if entry.get('desc_title'): 233 parts.append(entry['desc_title'] + '.') 234 else: 235 # Build description from available data 236 ms_name = 'the BL copy' if ms == 'C.60.o.12' else 'the Siena copy' 237 if entry.get('hand_label') and entry.get('hand_attribution'): 238 hand_desc = f"Hand {entry['hand_label']}" 239 if entry['hand_attribution'] and entry['hand_attribution'] != 'Anonymous': 240 hand_desc += f" ({entry['hand_attribution']})" 241 if entry.get('is_alchemist'): 242 hand_desc += ', an alchemist' 243 parts.append(f"Annotated by {hand_desc} in {ms_name}.") 244 elif entry.get('hand_label'): 245 parts.append(f"Annotated by Hand {entry['hand_label']} in {ms_name}.") 246 else: 247 parts.append(f"A folio from {ms_name} referenced in Russell's thesis.") 248 249 if entry.get('marginal_text'): 250 mt = entry['marginal_text'] 251 if len(mt) > 5: 252 parts.append(f'Russell records the marginal note: "{mt[:80]}{"..." if len(mt) > 80 else ""}"') 253 254 entry['card_description'] = ' '.join(parts) 255 entries.append(entry) 256 sigs.add(sig) 257 258 data = { 259 'entries': entries, 260 'stats': { 261 'total_references': len(entries), 262 'unique_signatures': len(sigs), 263 'high_confidence_matches': sum(1 for e in entries if e['confidence'] == 'HIGH'), 264 'low_confidence_matches': sum(1 for e in entries if e['confidence'] == 'LOW'), 265 'needs_review': sum(1 for e in entries if e['needs_review']), 266 }, 267 'provenance': { 268 'source': 'hp.db v2', 269 'note': 'BL C.60.o.12 matches are LOW confidence (1545 edition, unverified photo-folio mapping)', 270 }, 271 } 272 273 out = SITE_DIR / 'data.json' 274 with open(out, 'w', encoding='utf-8') as f: 275 json.dump(data, f, indent=2, ensure_ascii=False) 276 print(f" data.json: {len(entries)} entries") 277 278 279# ============================================================ 280# Scholars pages (DB-driven) 281# ============================================================ 282 283def build_scholars_pages(conn): 284 """Generate scholars.html and scholar/*.html from DB + summaries.json. 285 286 Uses scholar_overview, is_historical_subject, and scholar_works data 287 to build rich profiles per docs/SCHOLAR_SPEC.md. 288 """ 289 # Load summaries for detailed content 290 summaries = [] 291 if SUMMARIES_PATH.exists(): 292 with open(SUMMARIES_PATH, encoding='utf-8') as f: 293 summaries = json.load(f) 294 295 # Index summaries by (author, title) for lookup 296 summary_lookup = {} 297 for s in summaries: 298 key = (s.get('author', ''), s.get('title', '')) 299 summary_lookup[key] = s 300 301 # Group summaries by author for backward compat 302 by_author_summaries = {} 303 for s in summaries: 304 author = s.get('author', 'Unknown') 305 by_author_summaries.setdefault(author, []).append(s) 306 307 cur = conn.cursor() 308 309 # Get all scholars with overview data 310 cur.execute(""" 311 SELECT id, name, specialization, hp_focus, bio_notes, 312 scholar_overview, is_historical_subject, 313 needs_review, source_method, review_status 314 FROM scholars ORDER BY name 315 """) 316 all_scholars = cur.fetchall() 317 318 # Get scholar_works with bibliography data 319 cur.execute(""" 320 SELECT sw.scholar_id, b.author, b.title, b.year, b.pub_type, 321 b.journal_or_publisher, sw.has_summary, sw.summary_source, 322 b.hp_relevance, b.in_collection 323 FROM scholar_works sw 324 JOIN bibliography b ON sw.bib_id = b.id 325 ORDER BY b.year DESC 326 """) 327 works_by_scholar = {} 328 for row in cur.fetchall(): 329 works_by_scholar.setdefault(row[0], []).append(row) 330 331 scholar_cards = [] 332 scholar_dir = SITE_DIR / 'scholar' 333 scholar_dir.mkdir(exist_ok=True) 334 pages_built = 0 335 336 for scholar in all_scholars: 337 (sid, name, specialization, hp_focus, bio_notes, 338 overview, is_hist, needs_rev, source_method, rev_status) = scholar 339 340 slug = slugify(name) 341 works = works_by_scholar.get(sid, []) 342 author_summaries = by_author_summaries.get(name, []) 343 344 # Collect topics from summaries 345 topics = set() 346 for p in author_summaries: 347 tc = p.get('topic_cluster', '') 348 if tc: 349 for t in tc.split(','): 350 topics.add(t.strip()) 351 352 badges = topic_badges_html(','.join(topics)) 353 review_html = review_badge_html(needs_rev, source_method) 354 355 # Historical figure badge 356 hist_badge = ' <span class="review-status-badge review-badge-provisional">Historical Figure</span>' if is_hist else '' 357 358 # Overview preview for card (300 chars) 359 overview_text = overview or '' 360 overview_first_para = overview_text.split('\n\n')[0] if overview_text else '' 361 overview_preview = overview_first_para[:300] 362 if len(overview_first_para) > 300: 363 overview_preview = overview_preview.rsplit(' ', 1)[0] + '...' 364 365 # Work count 366 work_count = len(works) or len(author_summaries) 367 work_label = f"{work_count} work{'s' if work_count != 1 else ''}" if work_count else "No works catalogued" 368 369 # Card 370 overview_card_html = '' 371 if overview_preview: 372 overview_card_html = f""" 373 <div class="scholar-overview-preview"> 374 <p>{escape(overview_preview)} 375 <a href="scholar/{slug}.html">Read more</a></p> 376 </div>""" 377 378 scholar_cards.append(f""" 379 <div class="scholar-card"> 380 <h3><a href="scholar/{slug}.html">{escape(name)}</a>{hist_badge} {review_html}</h3> 381 <div class="scholar-meta">{work_label} {badges}</div> 382 {overview_card_html} 383 </div>""") 384 385 # === Detail page === 386 387 # Full overview 388 overview_html = '' 389 if overview_text: 390 paras = overview_text.split('\n\n') 391 overview_html = ''.join(f'<p>{escape(p.strip())}</p>' for p in paras if p.strip()) 392 overview_html = f'<div class="scholar-overview">{overview_html}</div>' 393 394 # Works in archive (with summaries) 395 archive_works_html = '' 396 works_with_summaries = [w for w in works if w[6]] # has_summary = True 397 if not works_with_summaries and author_summaries: 398 # Fallback to summaries.json directly 399 for p in author_summaries: 400 tc = p.get('topic_cluster', '') 401 archive_works_html += f""" 402 <div class="paper-detail"> 403 <h4>{escape(p.get('title', ''))}</h4> 404 <div class="paper-meta">{escape(p.get('journal', ''))} ({p.get('year', '?')}) 405 {topic_badges_html(tc)}</div> 406 <div class="paper-summary-full"><p>{escape(p.get('summary', ''))}</p></div> 407 </div>""" 408 else: 409 for w in works_with_summaries: 410 _, bauthor, btitle, byear, bpubtype, bjournal, _, _, _, _ = w 411 # Find matching summary 412 summary_text = '' 413 for s in author_summaries: 414 if s.get('title', '') == btitle: 415 summary_text = s.get('summary', '') 416 break 417 tc = '' 418 for s in author_summaries: 419 if s.get('title', '') == btitle: 420 tc = s.get('topic_cluster', '') 421 break 422 423 archive_works_html += f""" 424 <div class="paper-detail"> 425 <h4>{escape(btitle or '')}</h4> 426 <div class="paper-meta">{escape(bjournal or '')} ({byear or '?'}) [{bpubtype or ''}] 427 {topic_badges_html(tc)}</div> 428 <div class="paper-summary-full"><p>{escape(summary_text)}</p></div> 429 </div>""" 430 431 # Other known works (without summaries) 432 other_works_html = '' 433 works_without_summaries = [w for w in works if not w[6]] 434 for w in works_without_summaries: 435 _, bauthor, btitle, byear, bpubtype, bjournal, _, _, _, _ = w 436 other_works_html += f""" 437 <div class="paper-detail"> 438 <h4>{escape(btitle or '')}</h4> 439 <div class="paper-meta">{escape(bjournal or '')} ({byear or '?'}) [{bpubtype or ''}]</div> 440 <p class="no-summary" style="color:var(--text-muted); font-style:italic; font-size:0.9rem">Summary not yet available.</p> 441 </div>""" 442 443 # Build sections 444 works_section = '' 445 if archive_works_html: 446 works_section += f'<h3>Works in Archive</h3>{archive_works_html}' 447 if other_works_html: 448 works_section += f'<h3>Other Known Works</h3>{other_works_html}' 449 if not works_section and not author_summaries: 450 works_section = '<p style="color:var(--text-muted)">No works catalogued yet.</p>' 451 452 # Provenance 453 provenance_html = f""" 454 <div class="provenance-section" style="margin-top:2rem; padding:1rem; background:var(--bg-card); border-radius:4px"> 455 <h4 style="margin-top:0">Review Status / Provenance</h4> 456 <p>{review_status_badge(rev_status or 'DRAFT')} Source: {escape(source_method or 'LLM_ASSISTED')}</p> 457 </div>""" 458 459 detail_body = f""" 460 <div class="scholar-detail"> 461 <p><a href="../scholars.html">← All Scholars</a></p> 462 <h2>{escape(name)}{hist_badge} {review_html}</h2> 463 {overview_html} 464 {works_section} 465 {provenance_html} 466 </div>""" 467 468 detail_page = page_shell(name, detail_body, active_nav='scholars', depth=1) 469 (scholar_dir / f'{slug}.html').write_text(detail_page, encoding='utf-8') 470 pages_built += 1 471 472 # Build index page 473 # Count stats 474 modern_count = sum(1 for s in all_scholars if not s[6]) 475 hist_count = sum(1 for s in all_scholars if s[6]) 476 477 index_body = f""" 478 <div class="scholars-grid"> 479 <div class="intro" style="margin-bottom:2rem"> 480 <h2>Scholars of the <em>Hypnerotomachia</em></h2> 481 <p>The scholarship on the <em>Hypnerotomachia Poliphili</em> is wide-ranging 482 and interdisciplinary. It spans bibliography, book history, architecture, 483 garden studies, allegory, philology, reception history, emblematic reading, 484 and the history of interpretation. This section organizes that scholarly 485 landscape by person: {modern_count} modern scholars and {hist_count} historical 486 figures, with {len(summaries)} article and monograph summaries.</p> 487 <p>These pages are meant to make the field legible at a glance. 488 Where content is LLM-assisted or otherwise provisional, that status 489 remains visible.</p> 490 </div> 491 {''.join(scholar_cards)} 492 </div>""" 493 494 index_page = page_shell('Scholars', index_body, active_nav='scholars') 495 (SITE_DIR / 'scholars.html').write_text(index_page, encoding='utf-8') 496 print(f" scholars.html + {pages_built} scholar pages") 497 498 499# ============================================================ 500# Dictionary pages 501# ============================================================ 502 503def review_status_badge(status): 504 """Generate a colored review status badge.""" 505 colors = { 506 'DRAFT': ('review-badge-draft', 'Draft'), 507 'REVIEWED': ('review-badge-reviewed', 'Reviewed'), 508 'VERIFIED': ('review-badge-verified', 'Verified'), 509 'PROVISIONAL': ('review-badge-provisional', 'Provisional'), 510 } 511 cls, label = colors.get(status, ('review-badge-draft', status or 'Draft')) 512 return f'<span class="review-status-badge {cls}">{label}</span>' 513 514 515def build_dictionary_pages(conn): 516 """Generate dictionary/index.html and dictionary/*.html from DB.""" 517 cur = conn.cursor() 518 dict_dir = SITE_DIR / 'dictionary' 519 dict_dir.mkdir(exist_ok=True) 520 521 # Get all terms with enriched fields 522 cur.execute(""" 523 SELECT id, slug, label, category, definition_short, definition_long, 524 source_basis, review_status, needs_review, 525 significance_to_hp, significance_to_scholarship, 526 source_documents, source_page_refs, source_quotes_short, 527 source_method, confidence, notes, related_scholars 528 FROM dictionary_terms ORDER BY label 529 """) 530 terms = cur.fetchall() 531 532 # Build per-term link map 533 term_links = {} 534 cur.execute(""" 535 SELECT t1.slug, t2.slug, t2.label, l.link_type 536 FROM dictionary_term_links l 537 JOIN dictionary_terms t1 ON l.term_id = t1.id 538 JOIN dictionary_terms t2 ON l.linked_term_id = t2.id 539 """) 540 for row in cur.fetchall(): 541 term_links.setdefault(row[0], []).append({ 542 'slug': row[1], 'label': row[2], 'type': row[3] 543 }) 544 545 # Group by category for index 546 by_category = {} 547 for t in terms: 548 by_category.setdefault(t[3], []).append(t) 549 550 # Build index page 551 cat_sections = '' 552 for cat in sorted(by_category.keys()): 553 cat_terms = by_category[cat] 554 items = '' 555 for t in sorted(cat_terms, key=lambda x: x[2]): 556 review = ' <span class="review-badge">Draft</span>' if t[8] else '' 557 items += f""" 558 <div class="dict-entry"> 559 <h4><a href="{t[1]}.html">{escape(t[2])}</a>{review}</h4> 560 <p>{escape(t[4])}</p> 561 </div>""" 562 cat_sections += f""" 563 <section class="dict-category"> 564 <h3>{escape(cat)}</h3> 565 {items} 566 </section>""" 567 568 index_body = f""" 569 <div class="dictionary-index"> 570 <div class="intro"> 571 <h2>Dictionary of the <em>Hypnerotomachia</em></h2> 572 <p>The dictionary is the conceptual armature of the site. It defines 573 the {len(terms)} terms across {len(by_category)} categories that recur across the 574 <em>Hypnerotomachia</em> corpus and its scholarship—book-historical 575 terms, annotation concepts, alchemical vocabulary, architectural and garden 576 discourse, textual-visual rhetoric, and major historiographical debates. 577 Its purpose is not merely lexical; it is to help readers move between page 578 evidence and scholarly interpretation.</p> 579 <p>This section is most useful when read alongside the folio pages, essays, 580 and bibliography. Terms are cross-linked: each page lists related concepts 581 and see-also references, so you can follow threads through the HP's 582 intellectual world. Review and provenance badges indicate which entries 583 rest on stable ground and which are still in draft.</p> 584 </div> 585 {cat_sections} 586 </div>""" 587 588 dict_css = '<style>' + """ 589 .dictionary-index { max-width: 900px; margin: 2rem auto; padding: 0 2rem; } 590 .dict-category { margin-bottom: 2.5rem; } 591 .dict-category h3 { 592 font-size: 1.2rem; color: var(--accent); 593 border-bottom: 1px solid var(--border); 594 padding-bottom: 0.3rem; margin-bottom: 1rem; 595 } 596 .dict-entry { margin-bottom: 1rem; } 597 .dict-entry h4 { font-size: 1rem; margin-bottom: 0.2rem; } 598 .dict-entry h4 a { color: var(--text); text-decoration: none; } 599 .dict-entry h4 a:hover { color: var(--accent); } 600 .dict-entry p { font-size: 0.9rem; color: var(--text-muted); line-height: 1.5; } 601 .dict-detail { max-width: 800px; margin: 2rem auto; padding: 0 2rem; } 602 .dict-detail h2 { color: var(--accent); margin-bottom: 0.5rem; } 603 .dict-detail .category-label { 604 font-size: 0.85rem; color: var(--text-muted); 605 font-family: var(--font-sans); margin-bottom: 1.5rem; 606 } 607 .dict-detail .definition-short { 608 font-size: 1.1rem; font-style: italic; margin-bottom: 1.5rem; 609 padding: 1rem; background: var(--bg-card); border-left: 3px solid var(--accent); 610 } 611 .dict-detail .definition-long { line-height: 1.8; margin-bottom: 1.5rem; } 612 .dict-detail .source-basis { 613 font-size: 0.85rem; color: var(--text-muted); 614 font-family: var(--font-sans); margin-top: 1rem; 615 padding-top: 1rem; border-top: 1px solid var(--border); 616 } 617 .related-terms { margin-top: 1.5rem; } 618 .related-terms h4 { font-size: 0.95rem; color: var(--accent); margin-bottom: 0.5rem; } 619 .related-terms a { 620 display: inline-block; margin: 0.2rem 0.3rem 0.2rem 0; 621 padding: 0.2rem 0.6rem; background: var(--bg); 622 border: 1px solid var(--border); border-radius: 3px; 623 font-size: 0.85rem; color: var(--text); text-decoration: none; 624 } 625 .related-terms a:hover { border-color: var(--accent); color: var(--accent); } 626 """ + '</style>' 627 628 index_page = page_shell('Dictionary', index_body, active_nav='dictionary', depth=1) 629 (dict_dir / 'index.html').write_text(index_page, encoding='utf-8') 630 631 # Build individual term pages 632 for t in terms: 633 (tid, slug, label, category, def_short, def_long, source, status, 634 needs_rev, sig_hp, sig_schol, src_docs, src_pages, src_quotes, 635 src_method, confidence, notes, related_scholars) = t 636 links = term_links.get(slug, []) 637 638 related_html = '' 639 see_also_html = '' 640 for lk in links: 641 link_el = f'<a href="{lk["slug"]}.html">{escape(lk["label"])}</a>' 642 if lk['type'] == 'SEE_ALSO': 643 see_also_html += link_el 644 else: 645 related_html += link_el 646 647 links_section = '' 648 if related_html: 649 links_section += f'<div class="related-terms"><h4>Related Terms</h4>{related_html}</div>' 650 if see_also_html: 651 links_section += f'<div class="related-terms"><h4>See Also</h4>{see_also_html}</div>' 652 653 # Status badge 654 status_html = review_status_badge(status) 655 source_html = f'<div class="source-basis"><strong>Sources:</strong> {escape(source or "")}</div>' if source else '' 656 657 # Significance sections 658 sig_hp_html = '' 659 if sig_hp: 660 sig_hp_html = f''' 661 <div class="dict-section"> 662 <h3>Why It Matters for the <em>Hypnerotomachia</em></h3> 663 <p>{escape(sig_hp)}</p> 664 </div>''' 665 666 sig_schol_html = '' 667 if sig_schol: 668 sig_schol_html = f''' 669 <div class="dict-section"> 670 <h3>Why It Matters in Scholarship</h3> 671 <p>{escape(sig_schol)}</p> 672 </div>''' 673 674 # Evidence section 675 evidence_html = '' 676 if src_quotes: 677 quotes = src_quotes.split(' | ') 678 quote_items = ''.join(f'<li>{escape(q)}</li>' for q in quotes) 679 evidence_html = f''' 680 <div class="dict-section"> 681 <h3>Key Passages / Evidence</h3> 682 <ul class="evidence-list">{quote_items}</ul> 683 </div>''' 684 685 # Source documents 686 src_docs_html = '' 687 if src_docs: 688 src_docs_html = f''' 689 <div class="dict-section"> 690 <h3>Source Documents</h3> 691 <p class="source-docs">{escape(src_docs)}</p> 692 </div>''' 693 694 # Page references 695 src_pages_html = '' 696 if src_pages: 697 src_pages_html = f'<div class="source-pages"><strong>Page references:</strong> {escape(src_pages)}</div>' 698 699 # Related scholars 700 scholars_html = '' 701 if related_scholars: 702 scholars_html = f''' 703 <div class="dict-section"> 704 <h3>Related Scholars / Bibliography</h3> 705 <p>{escape(related_scholars)}</p> 706 </div>''' 707 708 # Provenance section 709 provenance_items = [] 710 if src_method: 711 provenance_items.append(f'Source method: {escape(src_method)}') 712 if confidence: 713 provenance_items.append(f'Confidence: {escape(confidence)}') 714 if notes: 715 provenance_items.append(f'Notes: {escape(notes)}') 716 provenance_html = '' 717 if provenance_items: 718 prov_list = ''.join(f'<li>{p}</li>' for p in provenance_items) 719 provenance_html = f''' 720 <div class="dict-section provenance-section"> 721 <h3>Review Status / Provenance</h3> 722 <div class="provenance-status">{status_html}</div> 723 <ul class="provenance-list">{prov_list}</ul> 724 </div>''' 725 726 # Essay cross-links: terms that appear in essays 727 essay_links_html = '' 728 russell_terms = { 729 'alchemical-allegory', 'master-mercury', 'sol-luna', 'chemical-wedding', 730 'ideogram', 'annotator-hand', 'activity-book', 'prisca-sapientia', 731 'ingegno', 'marginalia', 'elephant-obelisk', 'reception-history', 732 'inventio', 'acutezze', 'commentary', 'allegory', 733 'poliphilo', 'polia', 'venus-aphrodite', 'cupid-eros', 734 'beroalde-1600', '1499-edition', '1545-edition', 735 } 736 concordance_terms = { 737 'signature', 'folio', 'collation', 'quire', 'recto', 'verso', 738 'gathering', 'annotator-hand', 'marginalia', 'incunabulum', 739 '1499-edition', '1545-edition', 740 } 741 essay_links = [] 742 if slug in russell_terms: 743 essay_links.append('<a href="../russell-alchemical-hands.html">Alchemical Hands Essay</a>') 744 if slug in concordance_terms: 745 essay_links.append('<a href="../concordance-method.html">Concordance Methodology</a>') 746 if essay_links: 747 essay_links_html = f''' 748 <div class="related-terms"> 749 <h4>Discussed In</h4> 750 {''.join(essay_links)} 751 </div>''' 752 753 detail_body = f""" 754 <div class="dict-detail"> 755 <p><a href="index.html">← Dictionary</a></p> 756 <h2>{escape(label)} {status_html}</h2> 757 <div class="category-label">{escape(category)}</div> 758 <div class="definition-short">{escape(def_short)}</div> 759 <div class="definition-long">{escape(def_long or '')}</div> 760 {sig_hp_html} 761 {sig_schol_html} 762 {evidence_html} 763 {src_docs_html} 764 {src_pages_html} 765 {source_html} 766 {scholars_html} 767 {links_section} 768 {essay_links_html} 769 {provenance_html} 770 </div>""" 771 772 term_page = page_shell(label, detail_body, active_nav='dictionary', depth=1) 773 (dict_dir / f'{slug}.html').write_text(term_page, encoding='utf-8') 774 775 print(f" dictionary/index.html + {len(terms)} term pages") 776 777 778# ============================================================ 779# Marginalia folio detail pages 780# ============================================================ 781 782def _render_deep_reading(dr): 783 """Render a Phase 3 deep reading JSON object as HTML.""" 784 parts = [] 785 parts.append('<h3 style="margin:1.5rem 0 1rem">Vision Reading <span style="font-size:0.7rem;font-weight:400;color:var(--text-muted)">(Phase 3 deep analysis)</span></h3>') 786 787 # Metadata bar 788 meta_items = [] 789 if dr.get('primary_hand'): 790 meta_items.append(f'<strong>Primary hand:</strong> {escape(dr["primary_hand"])}') 791 if dr.get('hands_detected'): 792 meta_items.append(f'<strong>Hands:</strong> {dr["hands_detected"]}') 793 if dr.get('page_type'): 794 meta_items.append(f'<strong>Type:</strong> {escape(dr["page_type"])}') 795 if dr.get('signature'): 796 meta_items.append(f'<strong>Sig:</strong> {escape(dr["signature"])}') 797 if meta_items: 798 parts.append( 799 '<div style="font-size:0.85rem;font-family:var(--font-sans);color:var(--text-muted);' 800 f'margin-bottom:1rem">{" · ".join(meta_items)}</div>' 801 ) 802 803 # Woodcut analysis 804 wc = dr.get('woodcut_analysis') 805 if wc: 806 wc_html = f'<div class="marg-annotation" style="border-left:3px solid #6b8e5a">' 807 wc_html += f'<h4 style="color:#6b8e5a;margin-bottom:0.5rem">Woodcut: {escape(wc.get("subject", ""))}</h4>' 808 if wc.get('description'): 809 wc_html += f'<p style="line-height:1.7;margin-bottom:0.5rem">{escape(wc["description"])}</p>' 810 if wc.get('inscription_below'): 811 wc_html += f'<div class="marginal-text" style="border-color:#6b8e5a">{escape(wc["inscription_below"])}</div>' 812 if wc.get('condition'): 813 wc_html += f'<div class="hand-info">Condition: {escape(wc["condition"])}</div>' 814 wc_html += '</div>' 815 parts.append(wc_html) 816 817 # Transcription attempts 818 transcriptions = dr.get('transcription_attempts', []) 819 if transcriptions: 820 parts.append('<h4 style="color:var(--accent);margin:1rem 0 0.5rem;font-size:0.95rem">Transcription Attempts</h4>') 821 for t in transcriptions: 822 conf_color = {'HIGH': '#2d8a4e', 'MEDIUM': '#b8860b', 'LOW': '#c44'}.get( 823 t.get('confidence', ''), '#888') 824 loc = escape(t.get('location', '')) 825 lang = escape(t.get('language', '')) 826 text = escape(t.get('text_partial', t.get('text', ''))) 827 notes = escape(t.get('notes', '')) 828 conf = t.get('confidence', '') 829 830 t_html = '<div class="marg-annotation">' 831 t_html += f'<div class="hand-info" style="margin-bottom:0.5rem">' 832 t_html += f'<strong>{loc}</strong>' 833 if lang: 834 t_html += f' · {lang}' 835 if conf: 836 t_html += (f' · <span style="color:{conf_color};font-weight:600">' 837 f'{conf}</span>') 838 t_html += '</div>' 839 if text: 840 t_html += f'<div class="marginal-text">{text}</div>' 841 if notes: 842 t_html += f'<div class="context">{notes}</div>' 843 t_html += '</div>' 844 parts.append(t_html) 845 846 # Symbols detected 847 symbols = dr.get('symbols_detected', []) 848 if symbols: 849 parts.append('<h4 style="color:var(--accent);margin:1rem 0 0.5rem;font-size:0.95rem">Symbols Detected</h4>') 850 for s in symbols: 851 s_html = '<div class="marg-annotation">' 852 s_html += f'<div class="hand-info"><strong>{escape(s.get("type", ""))}</strong>' 853 if s.get('location'): 854 s_html += f' · {escape(s["location"])}' 855 s_html += '</div>' 856 if s.get('description'): 857 s_html += f'<p style="line-height:1.7;margin:0.5rem 0">{escape(s["description"])}</p>' 858 s_html += '</div>' 859 parts.append(s_html) 860 861 # Scholarly significance 862 if dr.get('scholarly_significance'): 863 parts.append( 864 '<div class="marg-annotation" style="border-left:3px solid var(--accent);background:var(--bg)">' 865 '<h4 style="color:var(--accent);margin-bottom:0.5rem">Scholarly Significance</h4>' 866 f'<p style="line-height:1.7">{escape(dr["scholarly_significance"])}</p>' 867 '</div>' 868 ) 869 870 # Cross-references 871 xrefs = dr.get('cross_references', []) 872 if xrefs: 873 refs_html = ', '.join(escape(x) for x in xrefs) 874 parts.append( 875 f'<div class="hand-info" style="margin-top:0.5rem">' 876 f'<strong>Cross-references:</strong> {refs_html}</div>' 877 ) 878 879 # Discrepancies 880 discreps = dr.get('discrepancies', []) 881 if discreps: 882 for d in discreps: 883 parts.append( 884 '<div style="background:#fff8e1;border:1px solid #f0e68c;border-radius:4px;' 885 'padding:0.75rem 1rem;margin:0.5rem 0;font-size:0.85rem">' 886 f'<strong>Note ({escape(d.get("type", ""))}):</strong> ' 887 f'{escape(d.get("description", ""))}</div>' 888 ) 889 890 # Provenance badge 891 parts.append( 892 '<div class="hand-info" style="margin-top:0.75rem">' 893 '<span class="review-badge">Vision reading (Claude Code, Phase 3)</span></div>' 894 ) 895 896 return '\n'.join(parts) 897 898 899def build_marginalia_pages(conn): 900 """Generate marginalia/index.html and marginalia/[signature].html.""" 901 cur = conn.cursor() 902 marg_dir = SITE_DIR / 'marginalia' 903 marg_dir.mkdir(exist_ok=True) 904 905 # Get all matched signatures with their images and annotations 906 cur.execute(""" 907 SELECT 908 a.signature_ref, a.thesis_page, dr.context_text, a.annotation_text, 909 a.thesis_chapter, m.shelfmark, m.institution, m.city, 910 i.filename, COALESCE(i.web_path, i.relative_path), i.folio_number, i.side, 911 mat.confidence, mat.needs_review, 912 h.hand_label, h.attribution, h.is_alchemist, h.school, 913 sm.quire, sm.leaf_in_quire, 914 a.annotation_type 915 FROM matches mat 916 JOIN annotations a ON mat.ref_id = a.id 917 JOIN images i ON mat.image_id = i.id 918 JOIN manuscripts m ON i.manuscript_id = m.id 919 LEFT JOIN dissertation_refs dr ON a.id = dr.id 920 LEFT JOIN signature_map sm ON LOWER(a.signature_ref) = LOWER(sm.signature) 921 LEFT JOIN annotator_hands h ON a.hand_id = h.id 922 WHERE i.page_type = 'PAGE' 923 ORDER BY COALESCE(sm.folio_number, 999), m.shelfmark 924 """) 925 926 # Group by signature 927 by_sig = {} 928 for row in cur.fetchall(): 929 sig = row[0] 930 by_sig.setdefault(sig, []).append(row) 931 932 marg_css = '<style>' + """ 933 .marg-detail { max-width: 1000px; margin: 2rem auto; padding: 0 2rem; } 934 .marg-detail h2 { color: var(--accent); margin-bottom: 0.5rem; } 935 .marg-images { display: grid; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); gap: 1.5rem; margin: 1.5rem 0; } 936 .marg-image-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 4px; overflow: hidden; } 937 .marg-image-card img { width: 100%; display: block; } 938 .marg-image-card .caption { padding: 0.75rem 1rem; font-size: 0.85rem; font-family: var(--font-sans); } 939 .marg-annotation { background: var(--bg-card); border: 1px solid var(--border); border-radius: 4px; padding: 1.5rem; margin-bottom: 1rem; } 940 .marg-annotation .marginal-text { font-style: italic; font-size: 1.05rem; padding: 0.75rem; border-left: 3px solid var(--accent-light); margin: 0.75rem 0; } 941 .marg-annotation .context { font-size: 0.9rem; color: var(--text-muted); line-height: 1.6; max-height: 200px; overflow-y: auto; } 942 .marg-annotation .hand-info { font-size: 0.85rem; font-family: var(--font-sans); color: var(--text-muted); margin-top: 0.5rem; } 943 .alchemist-tag { display: inline-block; padding: 0.1rem 0.5rem; background: #e8d4d4; color: #6b2323; border-radius: 2px; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; } 944 .marg-index { max-width: 1000px; margin: 2rem auto; padding: 0 2rem; } 945 .marg-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; } 946 .marg-grid a { display: block; padding: 0.75rem 1rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: 3px; text-decoration: none; color: var(--text); transition: all 0.2s; } 947 .marg-grid a:hover { border-color: var(--accent); color: var(--accent); transform: translateY(-1px); } 948 .marg-grid .sig-label { font-weight: 600; font-size: 1.1rem; color: var(--accent); } 949 .marg-grid .sig-meta { font-size: 0.8rem; color: var(--text-muted); font-family: var(--font-sans); } 950 """ + '</style>' 951 952 # Load folio descriptions (alchemist analyses) 953 folio_descs = {} 954 try: 955 cur2 = conn.cursor() 956 cur2.execute(""" 957 SELECT signature_ref, manuscript_shelfmark, hand_label, 958 title, description, alchemical_element, alchemical_process, 959 alchemical_framework, russell_page_ref 960 FROM folio_descriptions 961 """) 962 for fd in cur2.fetchall(): 963 folio_descs.setdefault(fd[0], []).append({ 964 'ms': fd[1], 'hand': fd[2], 'title': fd[3], 965 'desc': fd[4], 'element': fd[5], 'process': fd[6], 966 'framework': fd[7], 'pages': fd[8], 967 }) 968 except: 969 pass 970 971 # Load annotation types by signature 972 ann_types_by_sig = {} 973 try: 974 cur_at = conn.cursor() 975 cur_at.execute(""" 976 SELECT signature_ref, annotation_type, COUNT(*) FROM annotations 977 WHERE annotation_type IS NOT NULL 978 GROUP BY signature_ref, annotation_type 979 """) 980 for row in cur_at.fetchall(): 981 ann_types_by_sig.setdefault(row[0], []).append({ 982 'type': row[1], 'count': row[2] 983 }) 984 except: 985 pass 986 987 # Load symbol occurrences by signature 988 symbol_by_sig = {} 989 try: 990 cur3 = conn.cursor() 991 cur3.execute(""" 992 SELECT so.signature_ref, s.symbol_name, s.metal, s.planet, s.gender, 993 so.context_text, so.latin_inflection, so.thesis_page, so.confidence, 994 h.hand_label, h.manuscript_shelfmark 995 FROM symbol_occurrences so 996 JOIN alchemical_symbols s ON so.symbol_id = s.id 997 JOIN annotator_hands h ON so.hand_id = h.id 998 ORDER BY so.signature_ref, s.symbol_name 999 """) 1000 for row in cur3.fetchall(): 1001 symbol_by_sig.setdefault(row[0], []).append({ 1002 'symbol': row[1], 'metal': row[2], 'planet': row[3], 1003 'gender': row[4], 'context': row[5], 'inflection': row[6], 1004 'thesis_page': row[7], 'confidence': row[8], 1005 'hand': row[9], 'ms': row[10], 1006 }) 1007 except: 1008 pass 1009 1010 # Load Phase 3 deep readings by signature 1011 deep_by_sig = {} 1012 try: 1013 cur_dr = conn.cursor() 1014 cur_dr.execute(""" 1015 SELECT sm.signature, ir.deep_reading_json 1016 FROM image_readings ir 1017 JOIN images i ON ir.image_id = i.id 1018 JOIN signature_map sm ON ( 1019 CAST(REPLACE(REPLACE(i.filename, 'C_60_o_12-', ''), '.jpg', '') AS INTEGER) - 13 1020 ) = sm.id 1021 WHERE ir.phase = 3 AND ir.deep_reading_json IS NOT NULL 1022 """) 1023 for row in cur_dr.fetchall(): 1024 deep_by_sig[row[0].lower()] = row[1] 1025 except Exception as e: 1026 print(f" Warning: could not load deep readings: {e}") 1027 1028 # Build individual folio pages 1029 for sig, rows in by_sig.items(): 1030 sig_slug = sig.lower().replace(' ', '') 1031 1032 images_html = '' 1033 annotations_html = '' 1034 seen_images = set() 1035 1036 for row in rows: 1037 (sig_ref, thesis_page, context, marginal, chapter, 1038 shelfmark, institution, city, img_file, img_path, 1039 folio_num, side, confidence, needs_rev, 1040 hand_label, attribution, is_alchemist, school, 1041 quire, leaf, annotation_type) = row 1042 1043 # Image card (deduplicate) 1044 if img_file not in seen_images: 1045 seen_images.add(img_file) 1046 conf_badge = confidence_badge_html(confidence) 1047 rev_badge = '<span class="review-badge">Unverified</span>' if needs_rev else '' 1048 images_html += f""" 1049 <div class="marg-image-card"> 1050 <img src="../{img_path}" alt="Folio {sig}" loading="lazy"> 1051 <div class="caption"> 1052 {escape(institution)}, {escape(city)} — {escape(shelfmark)} 1053 {conf_badge} {rev_badge} 1054 </div> 1055 </div>""" 1056 1057 # Annotation card 1058 hand_html = '' 1059 if hand_label: 1060 alch_tag = ' <span class="alchemist-tag">Alchemist</span>' if is_alchemist else '' 1061 school_info = f' ({escape(school)})' if school else '' 1062 hand_html = f'<div class="hand-info">Hand {escape(hand_label)}: {escape(attribution or "Anonymous")}{school_info}{alch_tag}</div>' 1063 1064 type_html = '' 1065 if annotation_type: 1066 type_label = annotation_type.replace('_', ' ').title() 1067 type_html = f'<span style="display:inline-block;padding:0.1rem 0.5rem;background:var(--bg);border:1px solid var(--border);border-radius:2px;font-size:0.7rem;font-weight:600;text-transform:uppercase;margin-left:0.5rem;color:var(--text-muted)">{type_label}</span>' 1068 1069 marginal_html = '' 1070 if marginal: 1071 marginal_html = f'<div class="marginal-text">“{escape(marginal)}”</div>' 1072 1073 context_html = '' 1074 if context: 1075 ctx = context[:500] + ('...' if len(context) > 500 else '') 1076 context_html = f'<div class="context">{escape(ctx)}</div>' 1077 1078 annotations_html += f""" 1079 <div class="marg-annotation"> 1080 {hand_html}{type_html} 1081 {marginal_html} 1082 {context_html} 1083 <div class="hand-info">Russell, PhD Thesis, p. {thesis_page} (Ch. {chapter})</div> 1084 </div>""" 1085 1086 folio_info = f'Folio {rows[0][10] or "?"}{rows[0][11] or ""}' 1087 quire_info = f', Quire {rows[0][18]}' if rows[0][18] else '' 1088 1089 # Render folio description (alchemist analysis) if available 1090 desc_html = '' 1091 descs = folio_descs.get(sig, []) 1092 if descs: 1093 for fd in descs: 1094 element_html = f'<div class="hand-info"><strong>Element:</strong> {escape(fd["element"])}</div>' if fd.get('element') else '' 1095 process_html = f'<div class="hand-info"><strong>Process:</strong> {escape(fd["process"])}</div>' if fd.get('process') else '' 1096 framework_html = f'<div class="hand-info"><strong>Framework:</strong> {escape(fd["framework"])}</div>' if fd.get('framework') else '' 1097 pages_html = f'<div class="hand-info">Russell, {escape(fd["pages"])}</div>' if fd.get('pages') else '' 1098 desc_html += f""" 1099 <div class="marg-annotation" style="border-left:3px solid var(--accent); background:var(--bg)"> 1100 <h4 style="color:var(--accent); margin-bottom:0.5rem; font-size:1.05rem">{escape(fd["title"])}</h4> 1101 <p style="line-height:1.7; margin-bottom:0.75rem">{escape(fd["desc"])}</p> 1102 {element_html} 1103 {process_html} 1104 {framework_html} 1105 {pages_html} 1106 <div class="hand-info" style="margin-top:0.5rem"><span class="review-badge">LLM-assisted synthesis from Russell</span></div> 1107 </div>""" 1108 1109 # Symbol occurrences for this folio 1110 symbols_html = '' 1111 sig_symbols = symbol_by_sig.get(sig, []) 1112 if sig_symbols: 1113 sym_rows = '' 1114 for s in sig_symbols: 1115 conf = confidence_badge_html(s['confidence']) 1116 infl = f' <code>{escape(s["inflection"])}</code>' if s.get('inflection') else '' 1117 sym_rows += f"""<tr> 1118 <td><strong>{escape(s['symbol'])}</strong></td> 1119 <td>{escape(s.get('metal') or '')}</td> 1120 <td>{escape(s.get('planet') or '')}</td> 1121 <td>{escape(s.get('gender') or '')}</td> 1122 <td>Hand {escape(s['hand'])}{infl}</td> 1123 <td>{conf}</td> 1124 </tr>""" 1125 symbols_html = f""" 1126 <h3 style="margin:1.5rem 0 1rem">Alchemical Symbols Present</h3> 1127 <table style="width:100%; border-collapse:collapse; font-size:0.85rem; margin-bottom:1rem"> 1128 <thead><tr style="background:var(--bg)"> 1129 <th style="padding:0.5rem; border:1px solid var(--border)">Symbol</th> 1130 <th style="padding:0.5rem; border:1px solid var(--border)">Metal</th> 1131 <th style="padding:0.5rem; border:1px solid var(--border)">Planet</th> 1132 <th style="padding:0.5rem; border:1px solid var(--border)">Gender</th> 1133 <th style="padding:0.5rem; border:1px solid var(--border)">Hand</th> 1134 <th style="padding:0.5rem; border:1px solid var(--border)">Conf.</th> 1135 </tr></thead> 1136 <tbody>{sym_rows}</tbody> 1137 </table>""" 1138 1139 # Cross-links for alchemical folios 1140 alchem_cross = '' 1141 if desc_html or sig_symbols: 1142 links = ['<a href="../dictionary/alchemical-allegory.html">Alchemical Allegory</a>', 1143 '<a href="../russell-alchemical-hands.html">Alchemical Hands Essay</a>'] 1144 for s in sig_symbols: 1145 slug = slugify(s['symbol']) 1146 if slug in ('sol', 'mercury', 'hermaphrodite', 'sulphur'): 1147 term_slugs = {'sol': 'sol-luna', 'mercury': 'master-mercury', 1148 'hermaphrodite': 'chemical-wedding', 'sulphur': 'great-work'} 1149 ts = term_slugs.get(slug) 1150 if ts: 1151 links.append(f'<a href="../dictionary/{ts}.html">{escape(s["symbol"])}</a>') 1152 alchem_cross = f""" 1153 <div style="margin-top:1rem; padding-top:0.5rem; border-top:1px solid var(--border)"> 1154 <strong style="font-size:0.85rem">Related:</strong> {''.join(set(links))} 1155 </div>""" 1156 1157 detail_body = f""" 1158 <div class="marg-detail"> 1159 <p><a href="index.html">← All Folios</a></p> 1160 <h2>Signature {escape(sig)}</h2> 1161 <p style="color:var(--text-muted); font-family:var(--font-sans); margin-bottom:1.5rem"> 1162 {folio_info}{quire_info}</p> 1163 <div class="marg-images">{images_html}</div> 1164 {f'<h3 style="margin:1.5rem 0 1rem">Alchemical Analysis</h3>' + desc_html if desc_html else ''} 1165 {symbols_html} 1166 {alchem_cross}""" 1167 1168 # Deep reading section (Phase 3 vision analysis) 1169 deep_json = deep_by_sig.get(sig.lower()) 1170 if deep_json: 1171 import json as _json 1172 try: 1173 dr = _json.loads(deep_json) 1174 dr_html = _render_deep_reading(dr) 1175 detail_body += dr_html 1176 except Exception: 1177 pass 1178 1179 detail_body += """ 1180 <h3 style="margin:1.5rem 0 1rem">Annotations</h3>""" 1181 1182 # Add annotation type badges 1183 ann_types = ann_types_by_sig.get(sig, []) 1184 if ann_types: 1185 type_badges = '' 1186 for at in ann_types: 1187 type_badges += ( 1188 f'<span style="display:inline-block;padding:0.15rem 0.5rem;' 1189 f'margin:0.1rem;border-radius:3px;font-size:0.7rem;font-weight:600;' 1190 f'font-family:var(--font-sans);background:var(--bg);' 1191 f'border:1px solid var(--border)">' 1192 f'{escape(at["type"])} ({at["count"]})</span>' 1193 ) 1194 detail_body += f'\n <div style="margin-bottom:0.75rem">{type_badges}</div>' 1195 1196 detail_body += f""" 1197 {annotations_html} 1198 </div>""" 1199 1200 detail_page = page_shell(f'Folio {sig}', detail_body, active_nav='marginalia', depth=1) 1201 (marg_dir / f'{sig_slug}.html').write_text(detail_page, encoding='utf-8') 1202 1203 # Build standalone pages for deep readings without Russell annotations 1204 standalone_dr = 0 1205 for dr_sig, dr_json in deep_by_sig.items(): 1206 # Skip if already rendered in a marginalia page 1207 if any(dr_sig == s.lower() for s in by_sig.keys()): 1208 continue 1209 1210 sig_slug = dr_sig.lower().replace(' ', '') 1211 import json as _json 1212 try: 1213 dr = _json.loads(dr_json) 1214 except Exception: 1215 continue 1216 1217 # Look up folio info from signature_map 1218 sm_row = cur.execute( 1219 "SELECT folio_number, side, quire FROM signature_map WHERE LOWER(signature) = ?", 1220 (dr_sig,) 1221 ).fetchone() 1222 folio_info = f'Folio {sm_row[0]}{sm_row[1]}' if sm_row else '' 1223 quire_info = f', Quire {sm_row[2]}' if sm_row and sm_row[2] else '' 1224 1225 # Get BL image if available 1226 page_num = None 1227 if sm_row: 1228 page_num_row = cur.execute( 1229 "SELECT id FROM signature_map WHERE LOWER(signature) = ?", (dr_sig,) 1230 ).fetchone() 1231 if page_num_row: 1232 page_num = page_num_row[0] 1233 1234 img_html = '' 1235 if page_num: 1236 bl_photo = page_num + 13 1237 img_row = cur.execute( 1238 "SELECT COALESCE(web_path, relative_path), filename FROM images WHERE filename LIKE ?", 1239 (f'C_60_o_12-{bl_photo:03d}%',) 1240 ).fetchone() 1241 if img_row: 1242 img_html = f""" 1243 <div class="marg-images"> 1244 <div class="marg-image-card"> 1245 <img src="../{img_row[0]}" alt="Folio {dr_sig}" loading="lazy"> 1246 <div class="caption">British Library, London — C.60.o.12</div> 1247 </div> 1248 </div>""" 1249 1250 dr_html = _render_deep_reading(dr) 1251 1252 detail_body = f""" 1253 <div class="marg-detail"> 1254 <p><a href="index.html">← All Folios</a></p> 1255 <h2>Signature {escape(dr_sig)}</h2> 1256 <p style="color:var(--text-muted); font-family:var(--font-sans); margin-bottom:1.5rem"> 1257 {folio_info}{quire_info}</p> 1258 {img_html} 1259 {dr_html} 1260 </div>""" 1261 1262 detail_page = page_shell(f'Folio {dr_sig}', detail_body, active_nav='marginalia', depth=1) 1263 (marg_dir / f'{sig_slug}.html').write_text(detail_page, encoding='utf-8') 1264 by_sig[dr_sig] = [] # Add to index 1265 standalone_dr += 1 1266 1267 # Build marginalia index 1268 grid_items = '' 1269 def _sig_sort_key(s): 1270 rows = by_sig[s] 1271 if rows: 1272 return (str(rows[0][18] or 'zzz'), int(rows[0][10] or 999)) 1273 # Standalone deep-reading pages: sort by signature string 1274 return (s[0] if s else 'zzz', int(''.join(c for c in s if c.isdigit()) or '999')) 1275 1276 for sig in sorted(by_sig.keys(), key=_sig_sort_key): 1277 rows = by_sig[sig] 1278 sig_slug = sig.lower().replace(' ', '') 1279 1280 if rows: 1281 n_images = len(set(r[8] for r in rows)) 1282 n_annotations = len(rows) 1283 has_alchemist = any(r[16] for r in rows) 1284 alch = ' <span class="alchemist-tag">Alch.</span>' if has_alchemist else '' 1285 meta = f'{n_images} image{"s" if n_images != 1 else ""}, {n_annotations} ref{"s" if n_annotations != 1 else ""}' 1286 else: 1287 alch = '' 1288 has_dr = sig.lower() in deep_by_sig 1289 meta = 'Vision reading only' if has_dr else 'No annotations' 1290 1291 grid_items += f""" 1292 <a href="{sig_slug}.html"> 1293 <div class="sig-label">{escape(sig)}{alch}</div> 1294 <div class="sig-meta">{meta}</div> 1295 </a>""" 1296 1297 index_body = f""" 1298 <div class="marg-index"> 1299 <div class="intro"> 1300 <h2>Marginalia by Folio</h2> 1301 <p>Each card below represents a folio of the <em>Hypnerotomachia 1302 Poliphili</em> where marginal annotations have been documented in 1303 James Russell's PhD thesis. Folios are identified by their 1304 <strong>signature</strong>—a bibliographic notation combining 1305 a quire letter, leaf number, and side (<em>r</em> for recto, 1306 <em>v</em> for verso). For example, <em>b6v</em> is the verso of 1307 the sixth leaf in quire <em>b</em>.</p> 1308 <p>Folios tagged <span class="alchemist-tag" style="font-size:0.65rem">Alch.</span> 1309 contain annotations by one of two identified alchemist readers: 1310 Hand B in the British Library copy (a follower of d'Espagnet's 1311 mercury-centered framework) or Hand E in the Buffalo copy 1312 (a follower of pseudo-Geber's sulphur and Sol/Luna emphasis).</p> 1313 <p>{len(by_sig)} annotated folios from {len(set(r[5] for rows in by_sig.values() for r in rows))} manuscript copies.</p> 1314 </div> 1315 <div class="marg-grid">{grid_items}</div> 1316 </div>""" 1317 1318 index_page = page_shell('Marginalia', index_body, active_nav='marginalia', depth=1) 1319 (marg_dir / 'index.html').write_text(index_page, encoding='utf-8') 1320 print(f" marginalia/index.html + {len(by_sig)} folio pages") 1321 1322 1323# ============================================================ 1324# Bibliography page 1325# ============================================================ 1326 1327def build_bibliography_page(conn): 1328 """Generate bibliography.html with full HP bibliography from DB.""" 1329 cur = conn.cursor() 1330 1331 # Get all bibliography entries 1332 cur.execute(""" 1333 SELECT id, author, title, year, pub_type, journal_or_publisher, 1334 hp_relevance, topic_cluster, in_collection, notes, 1335 review_status, needs_review 1336 FROM bibliography 1337 ORDER BY 1338 CASE WHEN year IS NULL THEN 9999 ELSE CAST(year AS INTEGER) END, 1339 author 1340 """) 1341 entries = cur.fetchall() 1342 1343 # Group by relevance 1344 by_relevance = {} 1345 for e in entries: 1346 rel = e[6] or 'TANGENTIAL' 1347 by_relevance.setdefault(rel, []).append(e) 1348 1349 relevance_order = ['PRIMARY', 'DIRECT', 'INDIRECT', 'TANGENTIAL'] 1350 relevance_labels = { 1351 'PRIMARY': 'Primary Sources & Editions', 1352 'DIRECT': 'HP Scholarship (Direct)', 1353 'INDIRECT': 'Related Studies', 1354 'TANGENTIAL': 'General References', 1355 } 1356 1357 bib_css = '<style>' + """ 1358 .bib-page { max-width: 900px; margin: 2rem auto; padding: 0 2rem; } 1359 .bib-section { margin-bottom: 2.5rem; } 1360 .bib-section h3 { 1361 font-size: 1.1rem; color: var(--accent); 1362 border-bottom: 1px solid var(--border); 1363 padding-bottom: 0.3rem; margin-bottom: 1rem; 1364 } 1365 .bib-entry { margin-bottom: 1rem; padding: 0.75rem 1rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: 3px; } 1366 .bib-entry .bib-author { font-weight: 600; } 1367 .bib-entry .bib-title { font-style: italic; } 1368 .bib-entry .bib-details { font-size: 0.85rem; color: var(--text-muted); font-family: var(--font-sans); margin-top: 0.25rem; } 1369 .bib-entry .bib-badges { margin-top: 0.3rem; } 1370 .bib-badge-collection { display: inline-block; padding: 0.1rem 0.4rem; background: #d4edda; color: #155724; border-radius: 2px; font-size: 0.65rem; font-weight: 600; font-family: var(--font-sans); text-transform: uppercase; } 1371 .bib-badge-missing { display: inline-block; padding: 0.1rem 0.4rem; background: #f8d7da; color: #721c24; border-radius: 2px; font-size: 0.65rem; font-weight: 600; font-family: var(--font-sans); text-transform: uppercase; } 1372 .bib-stats { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-bottom: 2rem; } 1373 .bib-stat { text-align: center; padding: 1rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: 4px; flex: 1; min-width: 120px; } 1374 .bib-stat .num { font-size: 1.5rem; font-weight: 700; color: var(--accent); display: block; } 1375 .bib-stat .lbl { font-size: 0.8rem; color: var(--text-muted); font-family: var(--font-sans); } 1376 """ + '</style>' 1377 1378 # Stats 1379 total = len(entries) 1380 in_coll = sum(1 for e in entries if e[8]) 1381 primary = len(by_relevance.get('PRIMARY', [])) 1382 direct = len(by_relevance.get('DIRECT', [])) 1383 needs_rev = sum(1 for e in entries if e[11]) 1384 1385 stats_html = f""" 1386 <div class="bib-stats"> 1387 <div class="bib-stat"><span class="num">{total}</span><span class="lbl">Total Works</span></div> 1388 <div class="bib-stat"><span class="num">{in_coll}</span><span class="lbl">In Collection</span></div> 1389 <div class="bib-stat"><span class="num">{primary}</span><span class="lbl">Primary Sources</span></div> 1390 <div class="bib-stat"><span class="num">{direct}</span><span class="lbl">Direct Scholarship</span></div> 1391 <div class="bib-stat"><span class="num">{needs_rev}</span><span class="lbl">Need Review</span></div> 1392 </div>""" 1393 1394 # Build sections 1395 sections_html = '' 1396 for rel in relevance_order: 1397 items = by_relevance.get(rel, []) 1398 if not items: 1399 continue 1400 1401 entries_html = '' 1402 for e in items: 1403 (eid, author, title, year, pub_type, journal, 1404 relevance, topic, in_coll_flag, notes, 1405 rev_status, needs_rev_flag) = e 1406 1407 year_str = f' ({year})' if year else '' 1408 journal_str = f'. {escape(journal)}' if journal else '' 1409 type_str = f' [{pub_type}]' if pub_type else '' 1410 1411 collection_badge = '<span class="bib-badge-collection">In Collection</span>' if in_coll_flag else '<span class="bib-badge-missing">Not in Collection</span>' 1412 review_badge = review_badge_html(needs_rev_flag) if needs_rev_flag else '' 1413 topic_html = topic_badges_html(topic) if topic else '' 1414 1415 entries_html += f""" 1416 <div class="bib-entry"> 1417 <div><span class="bib-author">{escape(author or "")}</span>{year_str}. 1418 <span class="bib-title">{escape(title)}</span>{journal_str}{type_str}.</div> 1419 <div class="bib-badges">{collection_badge} {topic_html} {review_badge}</div> 1420 </div>""" 1421 1422 label = relevance_labels.get(rel, rel) 1423 sections_html += f""" 1424 <section class="bib-section"> 1425 <h3>{label} ({len(items)})</h3> 1426 {entries_html} 1427 </section>""" 1428 1429 body = f""" 1430 <div class="bib-page"> 1431 <div class="intro"> 1432 <h2>Bibliography</h2> 1433 <p>This bibliography is not a simple reading list. It is the site's 1434 map of the scholarly field: a structured record of primary sources, 1435 direct <em>Hypnerotomachia</em> scholarship, and related studies that 1436 help explain the book's material form, reception, imagery, and 1437 interpretive traditions. Entries are grouped so that readers can 1438 distinguish the core literature from the wider scholarly surround.</p> 1439 <p>Because bibliography is one of the project's main trust surfaces, 1440 this section places emphasis on review state, collection status, 1441 and data hygiene. Each entry shows whether we hold the work in our 1442 collection and whether the citation has been verified. That 1443 uncertainty remains visible rather than hidden behind uniform 1444 presentation.</p> 1445 </div> 1446 {stats_html} 1447 {sections_html} 1448 </div>""" 1449 1450 page = page_shell('Bibliography', body, active_nav='bibliography') 1451 (SITE_DIR / 'bibliography.html').write_text(page, encoding='utf-8') 1452 1453 print(f" bibliography.html: {total} entries") 1454 1455 1456# ============================================================ 1457# About page 1458# ============================================================ 1459 1460def build_about_page(conn): 1461 cur = conn.cursor() 1462 1463 # Get stats 1464 stats = {} 1465 for table in ['documents', 'images', 'annotations', 'matches', 1466 'annotator_hands', 'bibliography', 'scholars', 'dictionary_terms', 1467 'timeline_events']: 1468 try: 1469 cur.execute(f"SELECT COUNT(*) FROM {table}") 1470 stats[table] = cur.fetchone()[0] 1471 except: 1472 stats[table] = 0 1473 1474 cur.execute("SELECT COUNT(*) FROM matches WHERE confidence='HIGH'") 1475 stats['high_conf'] = cur.fetchone()[0] 1476 cur.execute("SELECT COUNT(*) FROM matches WHERE confidence='LOW'") 1477 stats['low_conf'] = cur.fetchone()[0] 1478 cur.execute("SELECT COUNT(*) FROM bibliography WHERE in_collection=1") 1479 stats['in_collection'] = cur.fetchone()[0] 1480 1481 body = f""" 1482 <div class="scholar-detail"> 1483 <h2>About This Project</h2> 1484 1485 <div class="paper-detail"> 1486 <h3>What Is This?</h3> 1487 <div class="paper-summary-full"><p> 1488 The <em>Hypnerotomachia Poliphili</em>, published by Aldus Manutius 1489 in Venice in 1499, is among the most celebrated and least understood 1490 books of the Italian Renaissance. Written in a macaronic blend of 1491 Italian, Latin, Greek, and pseudo-hieroglyphs, and illustrated with 1492 172 woodcuts of extraordinary refinement, it tells the story of 1493 Poliphilo's dream-journey through ruined temples, enchanted gardens, 1494 and allegorical processions in pursuit of his beloved Polia. 1495 </p><p> 1496 This project documents the <em>readership</em> of the HP — not just 1497 the text itself, but what five centuries of readers did with it. 1498 James Russell's PhD thesis (Durham, 2014) conducted a world census 1499 of annotated copies, finding that readers as diverse as Ben Jonson, 1500 Pope Alexander VII, Benedetto Giovio, and anonymous alchemists left 1501 extensive marginalia recording their encounters with the text. 1502 This site presents Russell's findings alongside photographs of two 1503 annotated copies, a bibliography of HP scholarship, a dictionary of 1504 essential terminology, and the full documentation of how the platform 1505 was built. 1506 </p></div> 1507 </div> 1508 1509 <div class="paper-detail"> 1510 <h3>Database Statistics</h3> 1511 <div class="paper-summary-full"> 1512 <ul style="list-style:none; padding:0;"> 1513 <li><strong>{stats['images']}</strong> manuscript images catalogued</li> 1514 <li><strong>{stats['annotations']}</strong> annotations extracted from Russell's thesis</li> 1515 <li><strong>{stats['matches']}</strong> image-reference matches 1516 ({stats['high_conf']} high confidence, {stats['low_conf']} low/provisional)</li> 1517 <li><strong>{stats['annotator_hands']}</strong> annotator hands identified</li> 1518 <li><strong>{stats['bibliography']}</strong> works in bibliography 1519 ({stats['in_collection']} in our collection)</li> 1520 <li><strong>{stats['scholars']}</strong> scholar profiles</li> 1521 <li><strong>{stats['dictionary_terms']}</strong> dictionary terms</li> 1522 <li><strong>{stats['timeline_events']}</strong> timeline events</li> 1523 </ul> 1524 </div> 1525 </div> 1526 1527 <div class="paper-detail"> 1528 <h3>Data Provenance</h3> 1529 <div class="paper-summary-full"><p> 1530 Content on this site has varying levels of verification: 1531 </p> 1532 <ul> 1533 <li><strong>Verified</strong>: Signature maps, collation formulae, and image 1534 cataloging are deterministic and correct.</li> 1535 <li><strong>High confidence</strong>: Siena O.III.38 image matches use explicit 1536 recto/verso naming.</li> 1537 <li><strong>Low confidence</strong>: BL C.60.o.12 matches assume sequential photo 1538 numbers equal folio numbers. The BL copy is the 1545 edition; the signature map 1539 is based on the 1499. Manual verification needed.</li> 1540 <li><strong>Unreviewed</strong>: Scholar summaries, dictionary definitions, and 1541 hand attributions were generated with LLM assistance and have not been verified 1542 by a domain expert. These are marked with 1543 <span class="review-badge" style="font-size:0.7rem">Unreviewed</span> badges.</li> 1544 </ul> 1545 </div> 1546 </div> 1547 1548 <div class="paper-detail"> 1549 <h3>How to Rebuild</h3> 1550 <div class="paper-summary-full"><p> 1551 The site is generated from a SQLite database (<code>db/hp.db</code>). 1552 To rebuild all pages: 1553 </p> 1554 <pre style="background:var(--bg); padding:1rem; border-radius:4px; overflow-x:auto; font-size:0.85rem"> 1555python scripts/migrate_v2.py # Schema migration (idempotent) 1556python scripts/seed_dictionary.py # Dictionary terms (idempotent) 1557python scripts/build_site.py # Generate all HTML + JSON 1558 </pre> 1559 <p>Individual pipeline steps:</p> 1560 <pre style="background:var(--bg); padding:1rem; border-radius:4px; overflow-x:auto; font-size:0.85rem"> 1561python scripts/init_db.py # Initialize DB schema 1562python scripts/catalog_images.py # Catalog manuscript images 1563python scripts/build_signature_map.py # Build signature map 1564python scripts/extract_references.py # Extract refs from thesis PDF 1565python scripts/match_refs_to_images.py # Match refs to images 1566python scripts/add_hands.py # Add annotator hand profiles 1567python scripts/add_bibliography.py # Add bibliography and timeline 1568 </pre> 1569 </div> 1570 </div> 1571 </div>""" 1572 1573 page = page_shell('About', body, active_nav='about') 1574 (SITE_DIR / 'about.html').write_text(page, encoding='utf-8') 1575 print(" about.html") 1576 1577 1578# ============================================================ 1579# Documents tab 1580# ============================================================ 1581 1582# Document metadata: (filename, title, one-line summary) 1583DOC_METADATA = { 1584 # Core docs (root level) 1585 'SYSTEM.md': ('System Architecture', 'Architecture, data flow, operating modes, and constraints for the HP platform.'), 1586 'ONTOLOGY.md': ('Data Ontology', 'All 22 tables: canonical entities, deprecated tables, relationships, coverage, and confidence.'), 1587 'PIPELINE.md': ('Build Pipeline', 'Every script in execution order, from PDF ingestion through site generation.'), 1588 'INTERFACE.md': ('Interface Design', 'How data becomes pages: navigation, surfacing audit, page builders, design language.'), 1589 'ROADMAP.md': ('Execution Roadmap', 'What is BUILT, READY, BLOCKED, and SPECULATIVE. No hypothetical features.'), 1590 'README.md': ('README', 'Project overview for GitHub.'), 1591 # Archived docs (still published on the site for reference) 1592 'docs/archive/HPCONCORD.md': ('Concordance Methodology', 'How the 6-step folio-to-image concordance was built from Russell\'s thesis.'), 1593 'docs/archive/HPMIT.md': ('MIT Site Analysis', 'Reverse-engineering of the MIT Electronic Hypnerotomachia (1997).'), 1594 'docs/archive/HPMULTIMODAL.md': ('Multimodal RAG Study', 'Vision model proposal for reading 674 manuscript images.'), 1595 'docs/archive/MISTAKESTOAVOID.md': ('Mistakes to Avoid', 'Twelve hard-won lessons from this project.'), 1596 'docs/archive/IMAGEIDENTIFICATION.md': ('Image Identification', 'What Claude saw reading 69 BL manuscript photographs.'), 1597 'docs/archive/CONCORDANCEHACKING.md': ('Concordance Progress', 'How the concordance problem-solving progressed and metrics.'), 1598 'docs/archive/WOODCUTRESEARCHREPORT.md': ('Woodcut Research', '18 woodcuts: subjects, attribution, scholarly context.'), 1599 'docs/archive/ISIDORE4.md': ('System Critique', 'Concordance system audit: 7 issues, 0 integrity errors.'), 1600 'docs/archive/OUTWARDNOTDEEPER.md': ('Session Findings', 'Build outward not deeper: session discoveries and philosophy.'), 1601} 1602 1603# Script metadata: (filename, title, one-line summary) 1604SCRIPT_METADATA = { 1605 'init_db.py': ('Initialize Database', 'Creates SQLite schema (7 core tables) and catalogs PDFs/documents from the filesystem.'), 1606 'catalog_images.py': ('Catalog Images', 'Parses image filenames from BL and Siena collections into the images table with folio/side metadata.'), 1607 'build_signature_map.py': ('Build Signature Map', 'Generates the 448-entry signature-to-folio concordance from the Aldine collation formula (a-z, A-G).'), 1608 'extract_references.py': ('Extract References', 'Uses PyMuPDF + regex to extract 282 folio/signature references from Russell\'s PhD thesis PDF.'), 1609 'match_refs_to_images.py': ('Match Refs to Images', 'SQL join pipeline matching dissertation references to manuscript images via the signature map.'), 1610 'add_hands.py': ('Add Annotator Hands', 'Creates 11 annotator hand profiles and attributes dissertation references to specific hands.'), 1611 'add_bibliography.py': ('Add Bibliography', 'Populates bibliography (58 entries), scholars (29), timeline (39 events) from hardcoded research data.'), 1612 'migrate_v2.py': ('Schema Migration V2', 'Adds annotations, annotators, doc_folio_refs, dictionary tables, review/provenance columns. Downgrades BL confidence.'), 1613 'seed_dictionary.py': ('Seed Dictionary', 'Inserts 37 dictionary terms across 6 categories with 76 bidirectional cross-reference links.'), 1614 'build_site.py': ('Build Site', 'Unified site generator: exports data.json, builds all HTML pages (scholars, dictionary, marginalia, bibliography, docs, code, about).'), 1615 'build_scholar_profiles.py': ('Build Scholar Profiles (Legacy)', 'Original scholar page generator from summaries.json. Superseded by build_site.py.'), 1616 'export_showcase_data.py': ('Export Showcase Data (Legacy)', 'Original data.json exporter for the gallery. Superseded by build_site.py.'), 1617 'validate.py': ('Validate & QA', 'Checks data integrity (duplicate slugs, broken links, confidence distribution) and writes AUDIT_REPORT.md.'), 1618 'ingest_perplexity.py': ('Ingest Perplexity Research', 'Adds 9 bibliography entries and 3 timeline events from HPPERPLEXITY.txt web research.'), 1619 'pdf_to_markdown.py': ('PDF to Markdown', 'Extracts all PDFs to markdown with YAML frontmatter, page markers, and metadata lookup.'), 1620 'chunk_documents.py': ('Chunk Documents', 'Splits markdown files into ~1500-word semantic chunks for RAG/retrieval systems.'), 1621 'migrate_dictionary_v2.py': ('Dictionary Schema V2', 'Extends dictionary_terms with significance, source tracking, provenance, and confidence columns.'), 1622 'corpus_search.py': ('Corpus Search', 'Keyword-based search across markdown chunks and documents with provenance tracking.'), 1623 'dictionary_audit.py': ('Dictionary Audit', 'Audits dictionary coverage: missing fields, duplicate slugs, orphaned links, weak terms.'), 1624 'build_reading_packets.py': ('Build Reading Packets', 'Assembles structured research packets from corpus search for dictionary enrichment.'), 1625 'enrich_dictionary.py': ('Enrich Dictionary', 'Populates dictionary fields from reading packets with source provenance and review status.'), 1626 'build_essay_data.py': ('Build Essay Data', 'Extracts structured evidence from DB and corpus for the Russell and Concordance essays.'), 1627 'add_alchemist_descriptions.py': ('Add Alchemist Descriptions', 'Inserts 13 folio-specific scholarly descriptions for the two alchemist annotators from Russell Ch. 6-7.'), 1628 'seed_dictionary_v2.py': ('Seed Dictionary V2', 'Seeds 43 HP entity terms: characters, places, architecture, gardens, processions, aesthetics, materials.'), 1629 'seed_dictionary_v3.py': ('Seed Dictionary V3', 'Seeds 14 additional terms: narrative form, built form, aesthetics, alchemy, material culture.'), 1630 'generate_dictionary_significance.py': ('Generate Significance', 'Generates significance_to_hp and significance_to_scholarship prose for all 80+ dictionary terms.'), 1631 'link_scholars.py': ('Link Scholars', 'Links scholars to bibliography, tags historical figures, matches summaries.json to bibliography entries.'), 1632 'generate_scholar_overviews.py': ('Generate Scholar Overviews', 'Generates 2-3 paragraph overview prose for modern scholars and role descriptions for historical figures.'), 1633 'migrate_timeline.py': ('Timeline Migration', 'Adds category, medium, location, image_ref, confidence columns to timeline_events table.'), 1634 'seed_timeline_v2.py': ('Seed Timeline V2', 'Seeds ~30 new timeline events: art, literary influence, scholarly milestones, garden design.'), 1635 'seed_copies.py': ('Seed Copies', 'Creates hp_copies table and seeds six annotated copies with full metadata from Russell 2014.'), 1636} 1637 1638 1639def markdown_to_html(md_text): 1640 """Minimal markdown-to-HTML conversion for document display.""" 1641 import re 1642 lines = md_text.split('\n') 1643 html_lines = [] 1644 in_code = False 1645 in_list = False 1646 in_table = False 1647 table_header_done = False 1648 1649 for line in lines: 1650 # Code blocks 1651 if line.strip().startswith('```'): 1652 if in_code: 1653 html_lines.append('</code></pre>') 1654 in_code = False 1655 else: 1656 lang = line.strip()[3:] 1657 html_lines.append(f'<pre><code class="lang-{lang}">') 1658 in_code = True 1659 continue 1660 if in_code: 1661 html_lines.append(escape(line)) 1662 continue 1663 1664 # Close list if needed 1665 if in_list and not line.strip().startswith(('-', '*', '1.', '2.', '3.', '4.', '5.', '6.', '7.', '8.', '9.')): 1666 html_lines.append('</ul>') 1667 in_list = False 1668 1669 # Tables 1670 if '|' in line and line.strip().startswith('|'): 1671 cells = [c.strip() for c in line.strip().split('|')[1:-1]] 1672 if all(set(c) <= set('- :') for c in cells): 1673 table_header_done = True 1674 continue 1675 if not in_table: 1676 html_lines.append('<table class="doc-table">') 1677 in_table = True 1678 tag = 'th' if not table_header_done else 'td' 1679 row = ''.join(f'<{tag}>{escape(c)}</{tag}>' for c in cells) 1680 html_lines.append(f'<tr>{row}</tr>') 1681 continue 1682 elif in_table: 1683 html_lines.append('</table>') 1684 in_table = False 1685 table_header_done = False 1686 1687 stripped = line.strip() 1688 1689 # Headings 1690 if stripped.startswith('### '): 1691 html_lines.append(f'<h4>{escape(stripped[4:])}</h4>') 1692 elif stripped.startswith('## '): 1693 html_lines.append(f'<h3>{escape(stripped[3:])}</h3>') 1694 elif stripped.startswith('# '): 1695 html_lines.append(f'<h2>{escape(stripped[2:])}</h2>') 1696 elif stripped.startswith('---'): 1697 html_lines.append('<hr>') 1698 elif stripped.startswith(('- ', '* ')): 1699 if not in_list: 1700 html_lines.append('<ul>') 1701 in_list = True 1702 html_lines.append(f'<li>{escape(stripped[2:])}</li>') 1703 elif stripped.startswith('>'): 1704 html_lines.append(f'<blockquote>{escape(stripped[1:].strip())}</blockquote>') 1705 elif stripped == '': 1706 html_lines.append('') 1707 else: 1708 # Apply inline formatting 1709 text = escape(stripped) 1710 # Bold 1711 text = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', text) 1712 # Italic 1713 text = re.sub(r'\*(.+?)\*', r'<em>\1</em>', text) 1714 # Inline code 1715 text = re.sub(r'`(.+?)`', r'<code>\1</code>', text) 1716 html_lines.append(f'<p>{text}</p>') 1717 1718 if in_list: 1719 html_lines.append('</ul>') 1720 if in_table: 1721 html_lines.append('</table>') 1722 if in_code: 1723 html_lines.append('</code></pre>') 1724 1725 return '\n'.join(html_lines) 1726 1727 1728def build_docs_pages(): 1729 """Generate docs/index.html and docs/*.html from project markdown files.""" 1730 docs_dir = SITE_DIR / 'docs' 1731 docs_dir.mkdir(exist_ok=True) 1732 1733 doc_css = '<style>' + """ 1734 .docs-page { max-width: 900px; margin: 2rem auto; padding: 0 2rem; } 1735 .docs-table { width: 100%; border-collapse: collapse; margin: 1.5rem 0; } 1736 .docs-table th, .docs-table td { padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid var(--border); } 1737 .docs-table th { font-family: var(--font-sans); font-size: 0.85rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; } 1738 .docs-table td a { color: var(--accent); text-decoration: none; font-weight: 600; } 1739 .docs-table td a:hover { text-decoration: underline; } 1740 .docs-table .doc-summary { font-size: 0.85rem; color: var(--text-muted); } 1741 .doc-content { max-width: 800px; margin: 2rem auto; padding: 0 2rem; } 1742 .doc-content h2 { color: var(--accent); margin: 2rem 0 0.5rem; } 1743 .doc-content h3 { color: var(--text); margin: 1.5rem 0 0.5rem; } 1744 .doc-content h4 { color: var(--text-muted); margin: 1rem 0 0.5rem; } 1745 .doc-content p { margin-bottom: 0.75rem; line-height: 1.7; } 1746 .doc-content pre { background: var(--bg); padding: 1rem; border-radius: 4px; overflow-x: auto; font-size: 0.85rem; margin: 1rem 0; } 1747 .doc-content code { font-size: 0.9em; background: var(--bg); padding: 0.1rem 0.3rem; border-radius: 2px; } 1748 .doc-content pre code { background: none; padding: 0; } 1749 .doc-content blockquote { border-left: 3px solid var(--accent-light); padding-left: 1rem; color: var(--text-muted); font-style: italic; margin: 1rem 0; } 1750 .doc-content ul { margin: 0.5rem 0 1rem 1.5rem; } 1751 .doc-content li { margin-bottom: 0.3rem; line-height: 1.6; } 1752 .doc-content hr { border: none; border-top: 1px solid var(--border); margin: 2rem 0; } 1753 .doc-content table.doc-table { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 0.9rem; } 1754 .doc-content table.doc-table th, .doc-content table.doc-table td { padding: 0.5rem 0.75rem; border: 1px solid var(--border); } 1755 .doc-content table.doc-table th { background: var(--bg); font-weight: 600; } 1756 """ + '</style>' 1757 1758 # Read all docs and build pages 1759 docs = [] 1760 for filename, (title, summary) in sorted(DOC_METADATA.items()): 1761 filepath = BASE_DIR / filename 1762 if not filepath.exists(): 1763 continue 1764 1765 slug = slugify(title) 1766 content = filepath.read_text(encoding='utf-8') 1767 word_count = len(content.split()) 1768 1769 docs.append({ 1770 'filename': filename, 1771 'title': title, 1772 'summary': summary, 1773 'slug': slug, 1774 'word_count': word_count, 1775 }) 1776 1777 # Build detail page 1778 content_html = markdown_to_html(content) 1779 detail_body = f""" 1780 <div class="doc-content"> 1781 <p><a href="index.html">← All Documents</a></p> 1782 <h2>{escape(title)}</h2> 1783 <p style="color:var(--text-muted); font-family:var(--font-sans); font-size:0.85rem; margin-bottom:1.5rem"> 1784 {escape(filename)} — {word_count:,} words</p> 1785 {content_html} 1786 </div>""" 1787 1788 detail_page = page_shell(title, detail_body, active_nav='docs', depth=1) 1789 (docs_dir / f'{slug}.html').write_text(detail_page, encoding='utf-8') 1790 1791 # Build index page 1792 rows_html = '' 1793 for d in docs: 1794 rows_html += f""" 1795 <tr> 1796 <td><a href="{d['slug']}.html">{escape(d['title'])}</a></td> 1797 <td class="doc-summary">{escape(d['summary'])}</td> 1798 <td style="font-family:var(--font-sans); font-size:0.85rem; white-space:nowrap">{d['word_count']:,}</td> 1799 </tr>""" 1800 1801 index_body = f""" 1802 <div class="docs-page"> 1803 <div class="intro"> 1804 <h2>Project Documents</h2> 1805 <p>This section makes the project legible as a research build 1806 rather than a black box. It gathers {len(docs)} internal methodological 1807 and reflective documents—ontology notes, concordance method, 1808 boundary audits, proposals, mistakes, aesthetic reviews, and related 1809 planning texts—that explain what the project thinks it is doing 1810 and where its assumptions have shifted over time.</p> 1811 <p>These documents are part of the scholarly apparatus, not just 1812 engineering residue. They record how the database, site, and 1813 interpretive claims were assembled, where the strongest arguments 1814 lie, and where the project has had to revise itself.</p> 1815 </div> 1816 <table class="docs-table"> 1817 <thead><tr><th>Document</th><th>Description</th><th>Words</th></tr></thead> 1818 <tbody>{rows_html}</tbody> 1819 </table> 1820 </div>""" 1821 1822 index_page = page_shell('Documents', index_body, active_nav='docs', depth=1) 1823 (docs_dir / 'index.html').write_text(index_page, encoding='utf-8') 1824 print(f" docs/index.html + {len(docs)} document pages") 1825 1826 1827def build_code_pages(): 1828 """Generate code/index.html and code/*.html from Python scripts.""" 1829 code_dir = SITE_DIR / 'code' 1830 code_dir.mkdir(exist_ok=True) 1831 1832 code_css = '<style>' + """ 1833 .code-page { max-width: 900px; margin: 2rem auto; padding: 0 2rem; } 1834 .code-table { width: 100%; border-collapse: collapse; margin: 1.5rem 0; } 1835 .code-table th, .code-table td { padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid var(--border); } 1836 .code-table th { font-family: var(--font-sans); font-size: 0.85rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; } 1837 .code-table td a { color: var(--accent); text-decoration: none; font-weight: 600; font-family: monospace; } 1838 .code-table td a:hover { text-decoration: underline; } 1839 .code-table .code-summary { font-size: 0.85rem; color: var(--text-muted); } 1840 .code-content { max-width: 1000px; margin: 2rem auto; padding: 0 2rem; } 1841 .code-content h2 { color: var(--accent); margin-bottom: 0.5rem; } 1842 .code-content pre { 1843 background: #1e1e1e; color: #d4d4d4; padding: 1.5rem; border-radius: 4px; 1844 overflow-x: auto; font-size: 0.82rem; line-height: 1.5; 1845 max-height: 80vh; overflow-y: auto; 1846 } 1847 .code-content .line-num { color: #858585; user-select: none; display: inline-block; width: 3.5em; text-align: right; margin-right: 1em; } 1848 .code-meta { font-family: var(--font-sans); font-size: 0.85rem; color: var(--text-muted); margin-bottom: 1.5rem; } 1849 """ + '</style>' 1850 1851 scripts = [] 1852 scripts_dir = BASE_DIR / 'scripts' 1853 1854 for filename, (title, summary) in sorted(SCRIPT_METADATA.items()): 1855 filepath = scripts_dir / filename 1856 if not filepath.exists(): 1857 continue 1858 1859 slug = slugify(filename.replace('.py', '')) 1860 content = filepath.read_text(encoding='utf-8') 1861 line_count = len(content.splitlines()) 1862 1863 scripts.append({ 1864 'filename': filename, 1865 'title': title, 1866 'summary': summary, 1867 'slug': slug, 1868 'line_count': line_count, 1869 }) 1870 1871 # Build detail page with syntax-highlighted code 1872 lines = content.splitlines() 1873 code_lines = [] 1874 for i, line in enumerate(lines, 1): 1875 num = f'<span class="line-num">{i}</span>' 1876 code_lines.append(f'{num}{escape(line)}') 1877 code_html = '\n'.join(code_lines) 1878 1879 detail_body = f""" 1880 <div class="code-content"> 1881 <p><a href="index.html">← All Scripts</a></p> 1882 <h2>{escape(title)}</h2> 1883 <div class="code-meta">{escape(filename)} — {line_count} lines</div> 1884 <p style="margin-bottom:1rem">{escape(summary)}</p> 1885 <pre>{code_html}</pre> 1886 </div>""" 1887 1888 detail_page = page_shell(title, detail_body, active_nav='code', depth=1) 1889 (code_dir / f'{slug}.html').write_text(detail_page, encoding='utf-8') 1890 1891 # Build index page 1892 rows_html = '' 1893 for s in scripts: 1894 rows_html += f""" 1895 <tr> 1896 <td><a href="{s['slug']}.html">{escape(s['filename'])}</a></td> 1897 <td>{escape(s['title'])}</td> 1898 <td class="code-summary">{escape(s['summary'])}</td> 1899 <td style="font-family:var(--font-sans); font-size:0.85rem; white-space:nowrap">{s['line_count']}</td> 1900 </tr>""" 1901 1902 index_body = f""" 1903 <div class="code-page"> 1904 <div class="intro"> 1905 <h2>Pipeline Scripts</h2> 1906 <p>This section exposes the deterministic pipeline behind the site. 1907 Rather than hiding the build logic, it presents these {len(scripts)} Python 1908 scripts that initialize the database, parse filenames, extract references, 1909 match folios to images, seed and enrich metadata, validate assumptions, 1910 and generate the static pages. The aim is transparency and reproducibility.</p> 1911 <p>The project's architectural stance is deliberately conservative: SQLite 1912 as source of truth, Python for transformation, JSON and static HTML for 1913 delivery, and as little framework machinery as possible. That simplicity 1914 is not an omission; it is part of the project's long-term durability model.</p> 1915 </div> 1916 <table class="code-table"> 1917 <thead><tr><th>Script</th><th>Name</th><th>Description</th><th>Lines</th></tr></thead> 1918 <tbody>{rows_html}</tbody> 1919 </table> 1920 </div>""" 1921 1922 index_page = page_shell('Code', index_body, active_nav='code', depth=1) 1923 (code_dir / 'index.html').write_text(index_page, encoding='utf-8') 1924 print(f" code/index.html + {len(scripts)} script pages") 1925 1926 1927# ============================================================ 1928# Essay: Russell's Alchemical Hands 1929# ============================================================ 1930 1931def _build_annotated_woodcuts_gallery(conn): 1932 """Build the annotated woodcuts gallery section for the Alchemical Hands page. 1933 1934 Shows the 18 CORPUS_EXTRACTION woodcuts with their scholarly apparatus: 1935 images, descriptions, annotation notes, dictionary links, and discussion. 1936 """ 1937 cur = conn.cursor() 1938 cur.execute(""" 1939 SELECT slug, title, signature_1499, page_1499, 1940 subject_category, description, narrative_context, 1941 has_annotation, alchemical_annotation, annotation_density, 1942 dictionary_terms, scholarly_discussion, influence, 1943 depicted_elements 1944 FROM woodcuts 1945 WHERE source_method = 'CORPUS_EXTRACTION' 1946 ORDER BY page_1499 1947 """) 1948 woodcuts = cur.fetchall() 1949 if not woodcuts: 1950 return '' 1951 1952 ia_img_dir = SITE_DIR / 'images' / 'woodcuts_1499' 1953 marg_dir = SITE_DIR / 'marginalia' 1954 dict_dir = SITE_DIR / 'dictionary' 1955 _ag_marg_sigs = {f.stem for f in marg_dir.glob('*.html') if f.stem != 'index'} if marg_dir.exists() else set() 1956 _ag_dict_slugs = {f.stem for f in dict_dir.glob('*.html') if f.stem != 'index'} if dict_dir.exists() else set() 1957 1958 cat_colors = { 1959 'ARCHITECTURAL': '#8b5cf6', 'LANDSCAPE': '#10b981', 'NARRATIVE': '#3b82f6', 1960 'HIEROGLYPHIC': '#f59e0b', 'PROCESSION': '#ef4444', 'DECORATIVE': '#6366f1', 1961 'PORTRAIT': '#ec4899', 'DIAGRAM': '#14b8a6', 1962 } 1963 1964 cards = '' 1965 for (slug, title, sig, page, cat, desc, narrative, 1966 has_ann, has_alch, ann_density, dict_terms, 1967 scholarly, influence, elements) in woodcuts: 1968 1969 color = cat_colors.get(cat, '#6b7280') 1970 img_filename = f'hp1499_p{page:03d}.jpg' 1971 img_exists = (ia_img_dir / img_filename).exists() 1972 1973 # Image 1974 if img_exists: 1975 img_tag = f'<img src="images/woodcuts_1499/{img_filename}" alt="{escape(title)}" style="width:100%;max-height:400px;object-fit:contain;border:1px solid var(--border);background:#f5f0e8;">' 1976 else: 1977 img_tag = '<div style="height:200px;background:#e8e0d0;display:flex;align-items:center;justify-content:center;color:#999">Image pending</div>' 1978 1979 # Badges 1980 badges = f'<span class="wc-cat-badge" style="background:{color}">{escape(cat or "")}</span>' 1981 if has_alch: 1982 badges += ' <span class="alchemist-tag">Alchemist</span>' 1983 if ann_density: 1984 badges += f' <span style="font-size:0.7rem;color:var(--text-muted)">{ann_density}</span>' 1985 1986 # Annotation note 1987 ann_note = '' 1988 if has_ann: 1989 ann_note = f'<p style="font-size:0.85rem;color:var(--accent);margin:0.5rem 0 0"><strong>BL annotations present</strong>' 1990 if has_alch: 1991 ann_note += ' (alchemical)' 1992 if sig and sig.lower() in _ag_marg_sigs: 1993 ann_note += f' — <a href="marginalia/{sig.lower()}.html">View folio {escape(sig)}</a>' 1994 ann_note += '</p>' 1995 1996 # Dictionary links 1997 dict_html = '' 1998 if dict_terms: 1999 terms = [t.strip() for t in dict_terms.split(',') if t.strip() and t.strip() in _ag_dict_slugs] 2000 dict_links = ' '.join(f'<a href="dictionary/{t}.html" style="font-size:0.75rem;padding:0.1rem 0.4rem;background:var(--bg);border:1px solid var(--border);border-radius:2px;text-decoration:none;color:var(--text)">{t.replace("-", " ").title()}</a>' for t in terms) 2001 dict_html = f'<div style="margin-top:0.5rem">{dict_links}</div>' 2002 2003 # Scholarly discussion 2004 schol_html = '' 2005 if scholarly: 2006 schol_html = f'<p style="font-size:0.85rem;color:var(--text-muted);margin-top:0.5rem;font-style:italic">{escape(scholarly[:200])}{"..." if len(scholarly) > 200 else ""}</p>' 2007 2008 cards += f""" 2009 <div style="background:var(--bg-card);border:1px solid var(--border);border-radius:6px;padding:1.25rem;margin-bottom:1.5rem"> 2010 <div style="display:grid;grid-template-columns:minmax(200px,350px) 1fr;gap:1.5rem;align-items:start"> 2011 <div>{img_tag}</div> 2012 <div> 2013 <h4 style="margin:0 0 0.3rem;font-size:1.05rem;color:var(--text)">{escape(title)}</h4> 2014 <div style="margin-bottom:0.5rem">{badges} <span style="font-size:0.8rem;color:var(--text-muted)">{escape(sig or "")} · p.{page}</span></div> 2015 <p style="line-height:1.7;margin:0">{escape(desc or "")}</p> 2016 {schol_html} 2017 {ann_note} 2018 {dict_html} 2019 </div> 2020 </div> 2021 </div>""" 2022 2023 alch_count = sum(1 for w in woodcuts if w[8]) # has_alch 2024 heavy_count = sum(1 for w in woodcuts if w[9] == 'HEAVY') 2025 2026 return f""" 2027 <h2 id="annotated-woodcuts">Woodcuts with Marginal Annotations 2028 <a class="section-anchor" href="#annotated-woodcuts">#</a></h2> 2029 <p>Russell's dissertation identified 18 woodcut pages in the BL copy (C.60.o.12) that bear 2030 marginal annotations. Of these, {alch_count} have specifically alchemical annotations by Hand B, 2031 and {heavy_count} have heavy annotation density. These pages represent the intersection of the 2032 HP's visual program with its readers' interpretive activity — the woodcuts that provoked 2033 the most active engagement from the annotating hands.</p> 2034 <p class="evidence-note">These woodcuts were extracted from Russell's corpus analysis. Each entry 2035 preserves the original scholarly description, annotation data, and dictionary cross-references. 2036 Click the folio link to view the full marginalia page with Phase 3 deep readings.</p> 2037 {cards}""" 2038 2039 2040def build_russell_essay_page(conn): 2041 """Generate russell-alchemical-hands.html from DB evidence + corpus data.""" 2042 cur = conn.cursor() 2043 2044 # Get alchemist hands 2045 cur.execute(""" 2046 SELECT hand_label, manuscript_shelfmark, attribution, school, 2047 description, language, ink_color, date_range, interests 2048 FROM annotator_hands WHERE is_alchemist = 1 2049 """) 2050 alchemist_hands = cur.fetchall() 2051 2052 # Get alchemist refs with signatures 2053 cur.execute(""" 2054 SELECT a.signature_ref, a.thesis_page, dr.context_text, a.annotation_text, 2055 a.thesis_chapter, h.manuscript_shelfmark, h.hand_label, h.school 2056 FROM annotations a 2057 JOIN annotator_hands h ON a.hand_id = h.id 2058 LEFT JOIN dissertation_refs dr ON a.id = dr.id 2059 WHERE h.is_alchemist = 1 2060 ORDER BY a.thesis_page 2061 """) 2062 alch_refs = cur.fetchall() 2063 2064 # Count matched images for alchemist refs 2065 cur.execute(""" 2066 SELECT mat.confidence, COUNT(*) 2067 FROM matches mat 2068 JOIN annotations a ON mat.ref_id = a.id 2069 JOIN annotator_hands h ON a.hand_id = h.id 2070 WHERE h.is_alchemist = 1 2071 GROUP BY mat.confidence 2072 """) 2073 conf_dist = {row[0]: row[1] for row in cur.fetchall()} 2074 2075 # Get specific BL refs 2076 bl_refs = [r for r in alch_refs if r[5] == 'C.60.o.12'] 2077 buf_refs = [r for r in alch_refs if r[5] == 'Buffalo RBR'] 2078 2079 # Build BL refs table 2080 bl_rows = '' 2081 for r in bl_refs[:20]: 2082 marginal = escape(r[3] or '') if r[3] else '<em>none recorded</em>' 2083 bl_rows += f'<tr><td>{escape(r[0] or "")}</td><td>{r[1]}</td><td>{marginal}</td></tr>' 2084 2085 buf_rows = '' 2086 for r in buf_refs[:20]: 2087 marginal = escape(r[3] or '') if r[3] else '<em>none recorded</em>' 2088 buf_rows += f'<tr><td>{escape(r[0] or "")}</td><td>{r[1]}</td><td>{marginal}</td></tr>' 2089 2090 essay_css = '<style>' + """ 2091 .essay-page { max-width: 850px; margin: 2rem auto; padding: 0 2rem; } 2092 .essay-page h2 { color: var(--accent); margin: 2rem 0 0.5rem; border-bottom: 1px solid var(--border); padding-bottom: 0.3rem; } 2093 .essay-page h3 { margin: 1.5rem 0 0.5rem; } 2094 .essay-page p { line-height: 1.8; margin-bottom: 1rem; } 2095 .essay-page .provisional { background: #fff3cd; padding: 0.5rem 1rem; border-left: 3px solid #ffc107; margin: 1rem 0; font-size: 0.9rem; } 2096 .essay-page .evidence-note { background: var(--bg-card); padding: 0.5rem 1rem; border-left: 3px solid var(--accent); margin: 1rem 0; font-size: 0.85rem; color: var(--text-muted); } 2097 .essay-page table { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 0.85rem; } 2098 .essay-page th, .essay-page td { padding: 0.5rem 0.75rem; border: 1px solid var(--border); text-align: left; } 2099 .essay-page th { background: var(--bg); font-weight: 600; } 2100 .essay-page blockquote { border-left: 3px solid var(--accent-light); padding-left: 1rem; color: var(--text-muted); font-style: italic; margin: 1rem 0; } 2101 .essay-page .section-anchor { color: var(--text-muted); text-decoration: none; font-size: 0.8em; margin-left: 0.3rem; } 2102 .essay-page .cross-links { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--border); } 2103 .essay-page .cross-links a { display: inline-block; margin: 0.2rem 0.3rem; padding: 0.2rem 0.6rem; background: var(--bg); border: 1px solid var(--border); border-radius: 3px; font-size: 0.85rem; color: var(--text); text-decoration: none; } 2104 .essay-page .cross-links a:hover { border-color: var(--accent); color: var(--accent); } 2105 """ + '</style>' 2106 2107 body = f""" 2108 <div class="essay-page"> 2109 <p><a href="index.html">← Home</a></p> 2110 2111 <h1>Russell's Research on the Alchemical Hands</h1> 2112 <p class="evidence-note">This essay synthesizes evidence from James Russell's PhD thesis 2113 (Durham, 2014) and data extracted by this project's concordance pipeline. All claims about 2114 specific folios and images are grounded in retrieved dissertation references and matched 2115 image data. Where claims depend on provisional BL concordance data, this is marked explicitly.</p> 2116 2117 <h2 id="overview">Overview of the Dissertation Project 2118 <a class="section-anchor" href="#overview">#</a></h2> 2119 <p>Russell's thesis, submitted to Durham University in 2014, documented the marginalia in six 2120 copies of the 1499 <em>Hypnerotomachia Poliphili</em>, identifying {len(alchemist_hands) + 9} 2121 distinct annotator hands across these copies. His work demonstrated that the HP was actively 2122 read and annotated by humanists, playwrights, a pope, and alchemists over roughly two centuries 2123 (1499–1700). The thesis framed the annotated HP as a "humanistic activity book" in which 2124 readers cultivated <em>ingegno</em> through creative engagement with the text and its woodcuts.</p> 2125 2126 <h2 id="annotated-copies">Annotated Copies Relevant to the Alchemical Interpretation 2127 <a class="section-anchor" href="#annotated-copies">#</a></h2> 2128 <p>Two of the six copies studied by Russell contain alchemical annotations:</p> 2129 <ul> 2130 <li><strong>British Library, C.60.o.12</strong> (the "BL copy"): Contains two hands. 2131 Hand A is attributed to Ben Jonson. Hand B is an anonymous alchemist, possibly connected 2132 to the Royal Society circle, following the framework of Jean d'Espagnet.</li> 2133 <li><strong>Buffalo Rare Book Room</strong> (the "Buffalo copy"): Contains five interleaved 2134 hands (A–E). Hand E is an anonymous alchemist following the pseudo-Geber (Jabir ibn Hayyan) 2135 school, emphasizing sulphur and Sol/Luna pairings.</li> 2136 </ul> 2137 2138 <h2 id="bl-alchemist">The BL Alchemical Hand (Hand B) 2139 <a class="section-anchor" href="#bl-alchemist">#</a></h2> 2140 <p>The BL alchemist (Hand B in C.60.o.12) is the more extensively documented of the two 2141 alchemical readers. Russell devotes Chapter 6 of his thesis to this hand, situating it within 2142 the broader tradition of seventeenth-century alchemical theory. The animating purpose of 2143 that theory was the recovery of <a href="dictionary/prisca-sapientia.html"><em>prisca sapientia</em></a>—an original wisdom, beginning 2144 with Hermes Trismegistus, that had been progressively obscured over the centuries. A text 2145 as complicated and linguistically obscure as the HP would have drawn much attention from 2146 an alchemist steeped in this tradition: the more recondite the text, the more ancient 2147 wisdom it was presumed to contain (Russell 2014, pp. 154–156).</p> 2148 2149 <div style="margin:1.5rem 0; text-align:center"> 2150 <img src="images/bl/C_60_o_12-003.jpg" alt="BL flyleaf verso: Master Mercury declaration" 2151 style="max-width:100%; border:1px solid var(--border); border-radius:4px" loading="lazy"> 2152 <p style="font-size:0.8rem; color:var(--text-muted); margin-top:0.3rem"> 2153 BL C.60.o.12, flyleaf verso (photo 003). Hand B's Master Mercury declaration. 2154 The shelfmark "C.60.o.12" and the date "1641" are visible below.</p> 2155 </div> 2156 2157 <h3>The <a href="dictionary/master-mercury.html" style="color:inherit">Master Mercury</a> Declaration</h3> 2158 <p>On the flyleaf verso, Hand B wrote a Latin summary declaring what the HP truly means:</p> 2159 <blockquote>"verus sensus intentionis huius libri est 3um: Geni et Totius Naturae energiae 2160 & operationum Magisteri Mercurii Descriptio elegans, ampla"</blockquote> 2161 <p><em>The true sense of the intention of this book is threefold: the full and elegant 2162 description of the energy and spirit of the whole nature, and of the operations of 2163 Master Mercury.</em></p> 2164 <p>This declaration positions mercury (quicksilver) as the central principle. In the 2165 alchemical framework of Jean d'Espagnet (1564–1637), whose <em>Enchiridion Physicae 2166 Restitutae</em> was available in English from 1651, mercury is the vehicle of the 2167 <em>spiritus mundi</em>—the world spirit emanating from the sun. Mercury was understood 2168 to be the primal metal, always liquid at room temperature, while all other metals achieved 2169 liquidity only through heat. It was, as Russell puts it, "all things to all people" in the 2170 alchemical tradition (Russell 2014, pp. 159–161).</p> 2171 <p>The annotator also twice praises the HP as "ingeniosissimo," recognizing it as exemplifying 2172 the <a href="dictionary/ingegno.html"><em>ingegno</em></a>—the improvisational 2173 intelligence—that early modern readers cultivated through annotation.</p> 2174 2175 <div style="margin:1.5rem 0; text-align:center"> 2176 <img src="images/bl/C_60_o_12-041.jpg" alt="BL b6v: Elephant and Obelisk with alchemical annotations" 2177 style="max-width:100%; border:1px solid var(--border); border-radius:4px" loading="lazy"> 2178 <p style="font-size:0.8rem; color:var(--text-muted); margin-top:0.3rem"> 2179 BL C.60.o.12, signature b6v (photo 041). The elephant and obelisk woodcut, 2180 densely annotated by Hand B with alchemical ideograms. This image later 2181 inspired Bernini's 1667 sculpture in Piazza della Minerva, Rome.</p> 2182 </div> 2183 2184 <h3>The Ideographic Vocabulary</h3> 2185 <p>Hand B used an extensive vocabulary of alchemical ideograms—compact symbols for 2186 gold, silver, mercury, Venus, Jupiter, and other elements—embedded within the syntax 2187 of Latin sentences. A particularly distinctive feature is the way B appended Latin case 2188 inflections to these symbols: "aurum" would be written as the gold symbol + "-um," and 2189 "Veneris" as the Venus symbol + "-eris." This creates a hybrid semiotic system where 2190 chemical signs function as Latin nouns within grammatical sentences 2191 (Russell 2014, pp. 149–152).</p> 2192 <p>Russell notes that B's ideographic vocabulary shows striking consistency with Isaac 2193 Newton's manuscripts in the Keynes collection at Cambridge. Although the BL hand is 2194 certainly not Newton's, this consistency "may suggest that B was attached to the Royal 2195 Society, from the environs of Cambridge, or was otherwise connected to the figure of 2196 Newton" (Russell 2014, pp. 158–159). A careful comparison of B's annotations with 2197 Newton's remains, Russell notes, a productive avenue for future research.</p> 2198 2199 <p class="evidence-note">Source: Russell 2014, Ch. 6, pp. 154–168. This project has 2200 {len(bl_refs)} dissertation references attributed to Hand B, and has visually confirmed 2201 the Master Mercury declaration and ideographic annotations on BL photographs 003 (flyleaf) 2202 and 041 (b6v, elephant and obelisk).</p> 2203 2204 <div style="display:flex; gap:1rem; flex-wrap:wrap; margin:1.5rem 0"> 2205 <div style="flex:1; min-width:250px; text-align:center"> 2206 <img src="images/bl/C_60_o_12-014.jpg" alt="BL a1r: opening page with annotations" 2207 style="max-width:100%; border:1px solid var(--border); border-radius:4px" loading="lazy"> 2208 <p style="font-size:0.8rem; color:var(--text-muted); margin-top:0.3rem"> 2209 a1r (photo 014). The HP's opening page: "POLIPHILO INCOMINCIA." 2210 Marginal annotations from both Hand A (Jonson) and Hand B (alchemist).</p> 2211 </div> 2212 <div style="flex:1; min-width:250px; text-align:center"> 2213 <img src="images/bl/C_60_o_12-004.jpg" alt="BL title page with Jonson ownership" 2214 style="max-width:100%; border:1px solid var(--border); border-radius:4px" loading="lazy"> 2215 <p style="font-size:0.8rem; color:var(--text-muted); margin-top:0.3rem"> 2216 Title page (photo 004). "RISTAMPATO DI NOVO... M.D.XXXXV" (1545). 2217 Ben Jonson's ownership inscription "Sum Ben: Ionsonij" at bottom.</p> 2218 </div> 2219 </div> 2220 2221 <h3>BL Alchemist: Referenced Folios</h3> 2222 <table> 2223 <thead><tr><th>Signature</th><th>Thesis Page</th><th>Marginal Text</th></tr></thead> 2224 <tbody>{bl_rows}</tbody> 2225 </table> 2226 <div class="evidence-note">BL image matches are HIGH confidence: the BL offset 2227 (page = photo number − 13) has been vision-verified at 174 of 174 readable pages 2228 with zero mismatches. The BL copy is the 1545 edition; the signature map is based on 2229 the 1499 collation, but the offset is confirmed stable across the entire volume.</div> 2230 2231 <h2 id="buffalo-alchemist">The Buffalo Alchemical Hand (Hand E) 2232 <a class="section-anchor" href="#buffalo-alchemist">#</a></h2> 2233 <p>Hand E in the Buffalo copy is the latest of five interleaved hands, overwriting Hand D. 2234 Like the BL alchemist, Hand E labels passages and woodcuts with the element or stage of 2235 the alchemical process they were presumed to allegorize. But Hand E follows a fundamentally 2236 different alchemical school: the pseudo-Geber tradition (from Jabir ibn Hayyan), which 2237 emphasizes sulphur and the <a href="dictionary/sol-luna.html">Sol/Luna</a> (Sun/Moon) 2238 duality rather than mercury (Russell 2014, pp. 170–186).</p> 2239 <p>In the Geberian framework, the masculine principle is represented by Sol (gold) and the 2240 feminine by Luna (silver). What makes Geber's thought distinctive is that these are understood 2241 as creative inverses: gold is masculine "in its height" and feminine "in its depth," while 2242 silver is feminine "in its height" and masculine "in its depth." It is the variance in these 2243 proportions that differentiates elements (Russell 2014, p. 187).</p> 2244 2245 <h3>The Chess Match as Alchemical Distillation</h3> 2246 <p>Hand E's most remarkable annotation is a sustained alchemical reading of the human chess 2247 match at Queen Eleuterylida's palace (g8r–h1r). Thirty-two maidens play, one side 2248 dressed in silver and the other in gold. But the queens of both sides wear gold, and the 2249 kings of both wear silver—the inverse of the Geberian ideal, signaling that transmutation 2250 has yet to occur.</p> 2251 <p>The annotator recorded the results of each of three rounds. Silver wins the first match; 2252 Hand E writes "Argentum" and draws a small crescent moon (Luna). Silver wins again in the 2253 second round. Gold finally triumphs in the third, and E initially writes "Rex ex auro factus 2254 victoriam ultimam"—then hesitates, corrects "Rex" to "[Re]gina," "auro" to "aura," 2255 "factus" to the solar symbol + "uestita," and "victor" to "victrix." E then cancels "Regina" 2256 and places "Auru(m)" as the culmination. The king of gold wins only after multiple rounds of 2257 play, each round representing a distillation (Russell 2014, pp. 188–189).</p> 2258 <p>The outcome of this alchemical marriage is a hermaphrodite—the <em>coincidentia 2259 oppositorum</em>, the union of opposite qualities. Hand E found this principle encoded in 2260 the epigram D.AMBIG.D.D. ("dedicated to the ambiguous gods") on a statue base (b5r), 2261 which E reads as "diis ambiguis id est metallis hermafroditis"—"to the ambiguous gods, 2262 that is, to the metallic hermaphrodites" (Russell 2014, pp. 189–190).</p> 2263 2264 <div style="margin:1.5rem 0; text-align:center"> 2265 <img src="images/bl/C_60_o_12-036.jpg" alt="BL b4r: Twin inscribed monuments with D.AMBIG.D.D." 2266 style="max-width:100%; border:1px solid var(--border); border-radius:4px" loading="lazy"> 2267 <p style="font-size:0.8rem; color:var(--text-muted); margin-top:0.3rem"> 2268 BL C.60.o.12, signature b4r (photo 036). The twin inscribed monuments bearing 2269 "D.AMBIG.D.D." — which Hand E in the Buffalo copy reads as "dedicated to 2270 the ambiguous gods, that is, to the metallic hermaphrodites." The BL copy 2271 shows this same passage heavily annotated by both hands.</p> 2272 </div> 2273 2274 <p class="evidence-note">Source: Russell 2014, Ch. 7, pp. 170–191. This project has 2275 {len(buf_refs)} dissertation references attributed to Hand E. The Buffalo copy has no 2276 photographs; images above show the same folios in the BL copy.</p> 2277 2278 <div style="margin:1.5rem 0; text-align:center"> 2279 <img src="images/bl/C_60_o_12-033.jpg" alt="BL page 20: alchemical ideograms visible in left margin" 2280 style="max-width:100%; border:1px solid var(--border); border-radius:4px" loading="lazy"> 2281 <p style="font-size:0.8rem; color:var(--text-muted); margin-top:0.3rem"> 2282 BL C.60.o.12, page 20 (photo 033). Alchemical ideograms visible in the left 2283 margin, showing Hand B's characteristic practice of embedding planetary 2284 symbols within Latin syntax. The pyramid passage is densely annotated.</p> 2285 </div> 2286 2287 <h3>Buffalo Alchemist: Referenced Folios</h3> 2288 <table> 2289 <thead><tr><th>Signature</th><th>Thesis Page</th><th>Marginal Text</th></tr></thead> 2290 <tbody>{buf_rows}</tbody> 2291 </table> 2292 2293 <h2 id="differences">Differences in Notation, Language, and Interpretive Logic 2294 <a class="section-anchor" href="#differences">#</a></h2> 2295 <table> 2296 <thead><tr><th>Feature</th><th>BL Hand B</th><th>Buffalo Hand E</th></tr></thead> 2297 <tbody> 2298 <tr><td>Alchemical School</td><td>d'Espagnet (mercury-centered)</td><td>pseudo-Geber (sulphur / Sol-Luna)</td></tr> 2299 <tr><td>Language</td><td>Latin with alchemical ideograms</td><td>Latin and Italian, fewer ideograms</td></tr> 2300 <tr><td>Central Principle</td><td>Master Mercury as catalytic agent</td><td>Sol/Luna duality and chemical wedding</td></tr> 2301 <tr><td>Key Passage</td><td>Flyleaf verso (Master Mercury declaration)</td><td>h1r (chess match as transmutation)</td></tr> 2302 <tr><td>Notation Style</td><td>Extensive ideographic vocabulary</td><td>Minimal ideograms; more discursive</td></tr> 2303 <tr><td>Approximate Date</td><td>Late 17th century (post-1623)</td><td>Unknown (latest of five hands)</td></tr> 2304 </tbody> 2305 </table> 2306 2307 <h2 id="images">What Images We Currently Have 2308 <a class="section-anchor" href="#images">#</a></h2> 2309 <p>This project has matched {sum(conf_dist.values())} images to alchemist-attributed 2310 dissertation references. The confidence distribution is:</p> 2311 <ul> 2312 <li>HIGH confidence: {conf_dist.get('HIGH', 0)} matches</li> 2313 <li>MEDIUM confidence: {conf_dist.get('MEDIUM', 0)} matches</li> 2314 <li>LOW confidence: {conf_dist.get('LOW', 0)} matches</li> 2315 </ul> 2316 <div class="evidence-note">All BL matches are HIGH confidence, verified by vision reading 2317 of 174 sequential BL photographs with a confirmed offset of 13 (zero mismatches).</div> 2318 2319 <h2 id="secure-vs-provisional">What Is Secure vs. Provisional 2320 <a class="section-anchor" href="#secure-vs-provisional">#</a></h2> 2321 <p><strong>Secure:</strong></p> 2322 <ul> 2323 <li>The existence and general character of two alchemical annotators (BL Hand B and Buffalo Hand E)</li> 2324 <li>The alchemical schools they followed (d'Espagnet vs. pseudo-Geber)</li> 2325 <li>The Master Mercury declaration on the BL flyleaf</li> 2326 <li>The folio signatures referenced by Russell in his thesis</li> 2327 <li>The signature map for the 1499 edition</li> 2328 <li>BL photograph-to-folio matches (HIGH confidence, vision-verified at 174/174 pages)</li> 2329 </ul> 2330 <p><strong>Provisional:</strong></p> 2331 <ul> 2332 <li>The attribution of Hand B to the Royal Society circle</li> 2333 <li>The precise dating of Hand E relative to the other Buffalo hands</li> 2334 <li>Any specific image claimed to show a particular alchemical annotation</li> 2335 </ul> 2336 2337 <h2 id="reception">Why the Alchemical Readers Matter for HP Reception History 2338 <a class="section-anchor" href="#reception">#</a></h2> 2339 <p>The alchemical annotations demonstrate that the HP's readership extended well beyond 2340 antiquarian humanists and poets. The two alchemist annotators independently decoded the 2341 HP as concealing alchemical formulae beneath its love narrative—yet they arrived 2342 at fundamentally different readings because they operated within different alchemical 2343 traditions. This diversity illustrates Russell's broader argument that the HP functioned 2344 as a "humanistic activity book": a text whose deliberate obscurity invited readers to 2345 impose their own interpretive frameworks.</p> 2346 <p>The alchemical tradition of reading the HP stretches back to Beroalde de Verville's 2347 1600 French edition, which included a "tableau steganographique" listing alchemical 2348 equivalents for the narrative's symbols. The BL and Buffalo annotations show that this 2349 tradition persisted into the seventeenth century and took distinct forms depending on 2350 the alchemical school of the reader.</p> 2351 2352 {_build_annotated_woodcuts_gallery(conn)} 2353 2354 <div class="cross-links"> 2355 <h4>Related Dictionary Terms</h4> 2356 <a href="dictionary/alchemical-allegory.html">Alchemical Allegory</a> 2357 <a href="dictionary/master-mercury.html">Master Mercury</a> 2358 <a href="dictionary/sol-luna.html">Sol and Luna</a> 2359 <a href="dictionary/chemical-wedding.html">Chemical Wedding</a> 2360 <a href="dictionary/ideogram.html">Ideogram</a> 2361 <a href="dictionary/prisca-sapientia.html">Prisca Sapientia</a> 2362 <a href="dictionary/annotator-hand.html">Annotator Hand</a> 2363 <a href="dictionary/activity-book.html">Activity Book</a> 2364 <a href="dictionary/ingegno.html">Ingegno</a> 2365 <a href="dictionary/elephant-obelisk.html">Elephant and Obelisk</a> 2366 <a href="dictionary/beroalde-1600.html">Beroalde 1600 Edition</a> 2367 <a href="dictionary/poliphilo.html">Poliphilo</a> 2368 <a href="dictionary/polia.html">Polia</a> 2369 <a href="dictionary/venus-aphrodite.html">Venus</a> 2370 <a href="dictionary/cupid-eros.html">Cupid / Eros</a> 2371 <h4>Related Pages</h4> 2372 <a href="concordance-method.html">Concordance Methodology</a> 2373 <a href="scholar/james-russell.html">James Russell</a> 2374 <a href="dictionary/reception-history.html">Reception History</a> 2375 </div> 2376 </div>""" 2377 2378 page = page_shell("Russell's Alchemical Hands", body, active_nav='russell', 2379 extra_css=essay_css) 2380 (SITE_DIR / 'russell-alchemical-hands.html').write_text(page, encoding='utf-8') 2381 print(" russell-alchemical-hands.html") 2382 2383 2384# ============================================================ 2385# Essay: Concordance Methodology 2386# ============================================================ 2387 2388def build_concordance_essay_page(conn): 2389 """Generate concordance-method.html explaining the matching pipeline.""" 2390 cur = conn.cursor() 2391 2392 # Gather stats 2393 cur.execute("SELECT COUNT(*) FROM signature_map") 2394 sig_count = cur.fetchone()[0] 2395 cur.execute("SELECT COUNT(*) FROM annotations") 2396 ref_count = cur.fetchone()[0] 2397 cur.execute("SELECT COUNT(*) FROM images") 2398 img_count = cur.fetchone()[0] 2399 cur.execute("SELECT COUNT(*) FROM matches") 2400 match_count = cur.fetchone()[0] 2401 2402 cur.execute("SELECT confidence, COUNT(*) FROM matches GROUP BY confidence") 2403 conf_dist = {row[0]: row[1] for row in cur.fetchall()} 2404 2405 cur.execute("SELECT match_method, COUNT(*) FROM matches GROUP BY match_method") 2406 method_dist = {row[0]: row[1] for row in cur.fetchall()} 2407 2408 cur.execute(""" 2409 SELECT m.shelfmark, mat.confidence, COUNT(*) 2410 FROM matches mat 2411 JOIN images i ON mat.image_id = i.id 2412 JOIN manuscripts m ON i.manuscript_id = m.id 2413 GROUP BY m.shelfmark, mat.confidence 2414 """) 2415 ms_conf = {} 2416 for row in cur.fetchall(): 2417 ms_conf.setdefault(row[0], {})[row[1]] = row[2] 2418 2419 siena_data = ms_conf.get('O.III.38', {}) 2420 bl_data = ms_conf.get('C.60.o.12', {}) 2421 2422 cur.execute(""" 2423 SELECT m.shelfmark, COUNT(DISTINCT i.id) FROM images i 2424 JOIN manuscripts m ON i.manuscript_id = m.id 2425 GROUP BY m.shelfmark 2426 """) 2427 img_by_ms = {row[0]: row[1] for row in cur.fetchall()} 2428 2429 cur.execute(""" 2430 SELECT manuscript_shelfmark, COUNT(*) FROM dissertation_refs 2431 WHERE manuscript_shelfmark IS NOT NULL 2432 GROUP BY manuscript_shelfmark ORDER BY COUNT(*) DESC 2433 """) 2434 ref_by_ms = {row[0]: row[1] for row in cur.fetchall()} 2435 2436 essay_css = '<style>' + """ 2437 .essay-page { max-width: 850px; margin: 2rem auto; padding: 0 2rem; } 2438 .essay-page h2 { color: var(--accent); margin: 2rem 0 0.5rem; border-bottom: 1px solid var(--border); padding-bottom: 0.3rem; } 2439 .essay-page h3 { margin: 1.5rem 0 0.5rem; } 2440 .essay-page p { line-height: 1.8; margin-bottom: 1rem; } 2441 .essay-page .provisional { background: #fff3cd; padding: 0.5rem 1rem; border-left: 3px solid #ffc107; margin: 1rem 0; font-size: 0.9rem; } 2442 .essay-page .evidence-note { background: var(--bg-card); padding: 0.5rem 1rem; border-left: 3px solid var(--accent); margin: 1rem 0; font-size: 0.85rem; color: var(--text-muted); } 2443 .essay-page table { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 0.85rem; } 2444 .essay-page th, .essay-page td { padding: 0.5rem 0.75rem; border: 1px solid var(--border); text-align: left; } 2445 .essay-page th { background: var(--bg); font-weight: 600; } 2446 .essay-page .process-flow {{ display: flex; flex-wrap: wrap; gap: 1rem; margin: 1.5rem 0; }} 2447 .essay-page .process-step {{ flex: 1; min-width: 200px; padding: 1rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: 4px; }} 2448 .essay-page .process-step .step-num {{ display: inline-block; width: 1.5rem; height: 1.5rem; background: var(--accent); color: white; border-radius: 50%; text-align: center; line-height: 1.5rem; font-size: 0.8rem; margin-right: 0.3rem; }} 2449 .essay-page .process-step h4 {{ margin: 0 0 0.5rem 0; font-size: 0.9rem; }} 2450 .essay-page .process-step p {{ font-size: 0.85rem; margin: 0; line-height: 1.5; }} 2451 .essay-page .section-anchor { color: var(--text-muted); text-decoration: none; font-size: 0.8em; margin-left: 0.3rem; } 2452 .essay-page .cross-links { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--border); } 2453 .essay-page .cross-links a { display: inline-block; margin: 0.2rem 0.3rem; padding: 0.2rem 0.6rem; background: var(--bg); border: 1px solid var(--border); border-radius: 3px; font-size: 0.85rem; color: var(--text); text-decoration: none; } 2454 .essay-page .cross-links a:hover { border-color: var(--accent); color: var(--accent); } 2455 """ + '</style>' 2456 2457 body = f""" 2458 <div class="essay-page"> 2459 <p><a href="index.html">← Home</a></p> 2460 2461 <h1>Concordance Methodology</h1> 2462 <p class="evidence-note">This page documents the actual methods used to build the concordance 2463 between Russell's dissertation references and the manuscript photograph collections. All 2464 statistics are drawn from the project database. Uncertainty and provisional status are 2465 marked explicitly throughout.</p> 2466 2467 <h2 id="problem">The Problem Russell Presents 2468 <a class="section-anchor" href="#problem">#</a></h2> 2469 <p>Russell's thesis references folios by signature (e.g., "b6v", "h1r") rather than by 2470 page number, since the 1499 edition has no printed pagination. Our manuscript photograph 2471 collections, however, use different naming conventions: the Siena images use folio numbers 2472 with recto/verso suffixes (e.g., O.III.38_0014r.jpg), while the BL images use sequential 2473 numbers (C_60_o_12-001.jpg through C_60_o_12-196.jpg). The core challenge is mapping 2474 Russell's {ref_count} signature-based references to the {img_count} available photographs.</p> 2475 2476 <h2 id="image-sets">BL vs. Siena Image Sets 2477 <a class="section-anchor" href="#image-sets">#</a></h2> 2478 <table> 2479 <thead><tr><th>Property</th><th>Siena O.III.38</th><th>BL C.60.o.12</th></tr></thead> 2480 <tbody> 2481 <tr><td>Images</td><td>{img_by_ms.get('O.III.38', 0)}</td><td>{img_by_ms.get('C.60.o.12', 0)}</td></tr> 2482 <tr><td>Naming</td><td>Folio number + r/v suffix</td><td>Sequential number only</td></tr> 2483 <tr><td>Edition</td><td>1499 Aldine</td><td>1545 Aldine (second edition)</td></tr> 2484 <tr><td>Folio mapping</td><td>Directly encoded in filename</td><td>Requires inference</td></tr> 2485 <tr><td>Matching confidence</td><td>HIGH / MEDIUM</td><td>LOW (all provisional)</td></tr> 2486 </tbody> 2487 </table> 2488 <div class="provisional">The BL copy is the 1545 edition, not the 1499. The signature map 2489 is based on the 1499 collation formula. While the 1545 edition largely follows the same 2490 structure, any pagination differences between editions would make sequential-number-to-folio 2491 inference unreliable. All BL matches must be treated as provisional.</div> 2492 2493 <h2 id="signature-map">How the Signature Map Works 2494 <a class="section-anchor" href="#signature-map">#</a></h2> 2495 <p>The signature map is a deterministic lookup table of {sig_count} entries generated from 2496 the 1499 collation formula: a–z<sup>8</sup> (omitting j, u, w), A–F<sup>8</sup>, 2497 G<sup>4</sup>. Each quire has 8 leaves (except G with 4), and each leaf has recto and verso 2498 sides. The map converts any valid signature (e.g., "b6v") to a sequential folio number 2499 and vice versa.</p> 2500 <p>This map is generated by <code>build_signature_map.py</code> and is fully deterministic. 2501 It is correct for the 1499 edition. Its applicability to the 1545 edition is assumed but 2502 not verified.</p> 2503 2504 <h2 id="ref-extraction">How Dissertation References Were Extracted 2505 <a class="section-anchor" href="#ref-extraction">#</a></h2> 2506 <p>Russell's thesis PDF was processed by <code>extract_references.py</code> using PyMuPDF 2507 for text extraction and regular expressions for signature pattern matching. The script 2508 extracted {ref_count} references, distributed across manuscripts:</p> 2509 <table> 2510 <thead><tr><th>Manuscript</th><th>References</th></tr></thead> 2511 <tbody>{''.join(f"<tr><td>{escape(k)}</td><td>{v}</td></tr>" for k, v in sorted(ref_by_ms.items(), key=lambda x: -x[1]))}</tbody> 2512 </table> 2513 2514 <h2 id="image-parsing">How Image Filenames Were Parsed 2515 <a class="section-anchor" href="#image-parsing">#</a></h2> 2516 <p><code>catalog_images.py</code> parses image filenames from two manuscript collections into 2517 structured records with folio number, side (recto/verso), and page type. The Siena images 2518 encode folio information directly (O.III.38_0014r.jpg = folio 14 recto). The BL images 2519 use sequential numbers that do not directly encode folio information.</p> 2520 2521 <h2 id="matching">How Matching Was Performed 2522 <a class="section-anchor" href="#matching">#</a></h2> 2523 <div class="process-flow"> 2524 <div class="process-step"> 2525 <h4><span class="step-num">1</span> Signature Lookup</h4> 2526 <p>Convert each dissertation reference's signature (e.g., "b6v") to a folio number 2527 using the signature map.</p> 2528 </div> 2529 <div class="process-step"> 2530 <h4><span class="step-num">2</span> Manuscript Filter</h4> 2531 <p>Filter images to the manuscript shelfmark specified in the dissertation reference.</p> 2532 </div> 2533 <div class="process-step"> 2534 <h4><span class="step-num">3</span> Folio Match</h4> 2535 <p>Match the computed folio number to images with the same folio number and side.</p> 2536 </div> 2537 <div class="process-step"> 2538 <h4><span class="step-num">4</span> Fallback Cross-Match</h4> 2539 <p>If no direct match, attempt cross-manuscript matching (lower confidence).</p> 2540 </div> 2541 <div class="process-step"> 2542 <h4><span class="step-num">5</span> Confidence Assignment</h4> 2543 <p>Assign HIGH (exact Siena match), MEDIUM (folio-based), or LOW (BL sequential inference).</p> 2544 </div> 2545 </div> 2546 2547 <h2 id="confidence">Where Confidence Is High vs. Low 2548 <a class="section-anchor" href="#confidence">#</a></h2> 2549 <table> 2550 <thead><tr><th>Confidence</th><th>Matches</th><th>Description</th></tr></thead> 2551 <tbody> 2552 <tr><td>HIGH</td><td>{conf_dist.get('HIGH', 0)}</td><td>Siena images with explicit folio+side in filename, matched by signature lookup</td></tr> 2553 <tr><td>MEDIUM</td><td>{conf_dist.get('MEDIUM', 0)}</td><td>Siena images matched by folio number (cross-manuscript or side ambiguous)</td></tr> 2554 <tr><td>LOW</td><td>{conf_dist.get('LOW', 0)}</td><td>BL images where sequential photo number is assumed to equal folio number</td></tr> 2555 </tbody> 2556 </table> 2557 2558 <h3>By Manuscript</h3> 2559 <table> 2560 <thead><tr><th>Manuscript</th><th>HIGH</th><th>MEDIUM</th><th>LOW</th></tr></thead> 2561 <tbody> 2562 <tr><td>Siena O.III.38</td><td>{siena_data.get('HIGH', 0)}</td><td>{siena_data.get('MEDIUM', 0)}</td><td>{siena_data.get('LOW', 0)}</td></tr> 2563 <tr><td>BL C.60.o.12</td><td>{bl_data.get('HIGH', 0)}</td><td>{bl_data.get('MEDIUM', 0)}</td><td>{bl_data.get('LOW', 0)}</td></tr> 2564 </tbody> 2565 </table> 2566 2567 <h2 id="bl-provisional">Why BL Matches Are Provisional 2568 <a class="section-anchor" href="#bl-provisional">#</a></h2> 2569 <div class="provisional"><strong>Critical caveat:</strong> All {sum(bl_data.values())} BL matches 2570 are classified as LOW confidence. The BL photographs are sequentially numbered (001–196) 2571 without folio labels. Our matching pipeline assumes that photo N corresponds to folio N, but 2572 this assumption is unverified. The BL copy is the 1545 edition, not the 1499 edition from which 2573 the signature map was built. Any differences in pagination, inserted leaves, or missing leaves 2574 between editions would invalidate this mapping. These matches require manual verification 2575 against the physical photographs.</div> 2576 2577 <h2 id="human-review">What Human Review Is Still Needed 2578 <a class="section-anchor" href="#human-review">#</a></h2> 2579 <ul> 2580 <li>Verify BL photograph-to-folio correspondence against physical or high-resolution images</li> 2581 <li>Confirm that the 1545 edition follows the same collation as the 1499</li> 2582 <li>Spot-check MEDIUM confidence Siena matches for side (recto/verso) accuracy</li> 2583 <li>Review hand attribution for edge cases where multiple hands annotate the same folio</li> 2584 <li>Validate marginal text transcriptions against original manuscript images</li> 2585 </ul> 2586 2587 <h2 id="future">How This Methodology Supports Future Scholarship 2588 <a class="section-anchor" href="#future">#</a></h2> 2589 <p>The concordance pipeline produces a structured, queryable dataset linking Russell's 2590 close reading to digital images. Once the BL matches are verified, this enables:</p> 2591 <ul> 2592 <li>Folio-level browsing of annotations alongside manuscript images</li> 2593 <li>Systematic comparison of annotation density across copies</li> 2594 <li>Identification of folios that attracted multiple annotators</li> 2595 <li>Cross-referencing between annotations and the dictionary of terms</li> 2596 <li>Future multimodal analysis using computer vision on manuscript images</li> 2597 </ul> 2598 2599 <div class="cross-links"> 2600 <h4>Related Dictionary Terms</h4> 2601 <a href="dictionary/signature.html">Signature</a> 2602 <a href="dictionary/folio.html">Folio</a> 2603 <a href="dictionary/recto.html">Recto</a> 2604 <a href="dictionary/verso.html">Verso</a> 2605 <a href="dictionary/quire.html">Quire</a> 2606 <a href="dictionary/collation.html">Collation</a> 2607 <a href="dictionary/incunabulum.html">Incunabulum</a> 2608 <a href="dictionary/annotator-hand.html">Annotator Hand</a> 2609 <a href="dictionary/marginalia.html">Marginalia</a> 2610 <a href="dictionary/1499-edition.html">1499 Edition</a> 2611 <a href="dictionary/1545-edition.html">1545 Edition</a> 2612 <h4>Related Pages</h4> 2613 <a href="russell-alchemical-hands.html">Alchemical Hands Essay</a> 2614 <a href="scholar/james-russell.html">James Russell</a> 2615 <a href="docs/concordance-methodology.html">Concordance Methodology (Doc)</a> 2616 <a href="code/match-refs-to-images.html">match_refs_to_images.py</a> 2617 <a href="code/build-signature-map.html">build_signature_map.py</a> 2618 </div> 2619 </div>""" 2620 2621 page = page_shell('Concordance Methodology', body, active_nav='concordance', 2622 extra_css=essay_css) 2623 (SITE_DIR / 'concordance-method.html').write_text(page, encoding='utf-8') 2624 print(" concordance-method.html") 2625 2626 2627# ============================================================ 2628# Timeline page 2629# ============================================================ 2630 2631def build_timeline_page(conn): 2632 """Generate timeline.html with chronological HP reception history.""" 2633 cur = conn.cursor() 2634 cur.execute(""" 2635 SELECT year, year_end, event_type, title, description, category, 2636 medium, location, confidence, manuscript_shelfmark 2637 FROM timeline_events ORDER BY year, id 2638 """) 2639 events = cur.fetchall() 2640 2641 # Group by year 2642 by_year = {} 2643 for e in events: 2644 by_year.setdefault(e[0], []).append(e) 2645 2646 # Category colors 2647 cat_colors = { 2648 'art': '#8b5cf6', 'scholarship': '#3b82f6', 'edition': '#10b981', 2649 'literary': '#f59e0b', '': '#6b7280', 2650 } 2651 cat_labels = { 2652 'art': 'Art & Design', 'scholarship': 'Scholarship', 'edition': 'Edition', 2653 'literary': 'Literary Influence', '': 'Other', 2654 } 2655 2656 timeline_css = '<style>' + """ 2657 .timeline-page { max-width: 900px; margin: 2rem auto; padding: 0 2rem; } 2658 .timeline-page h2 { color: var(--accent); } 2659 .timeline-page p { line-height: 1.8; } 2660 .timeline-filters { display: flex; gap: 0.5rem; flex-wrap: wrap; margin: 1.5rem 0; } 2661 .timeline-filters label { display: flex; align-items: center; gap: 0.3rem; font-size: 0.85rem; font-family: var(--font-sans); cursor: pointer; padding: 0.3rem 0.6rem; border: 1px solid var(--border); border-radius: 3px; } 2662 .timeline-filters input { margin: 0; } 2663 .timeline { position: relative; padding-left: 2rem; margin-top: 2rem; } 2664 .timeline::before { content: ''; position: absolute; left: 0.5rem; top: 0; bottom: 0; width: 2px; background: var(--border); } 2665 .timeline-year { margin-bottom: 2rem; position: relative; } 2666 .year-marker { position: sticky; top: 4rem; font-size: 1.3rem; font-weight: 700; color: var(--accent); font-family: var(--font-sans); margin-bottom: 0.5rem; padding-left: 0; margin-left: -2rem; background: var(--bg-main, #fff); z-index: 1; } 2667 .timeline-card { padding: 0.75rem 1rem; margin-bottom: 0.75rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: 4px; position: relative; } 2668 .timeline-card::before { content: ''; position: absolute; left: -1.55rem; top: 1rem; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); } 2669 .card-type-badge { display: inline-block; font-size: 0.65rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; padding: 0.1rem 0.4rem; border-radius: 2px; color: white; margin-bottom: 0.3rem; font-family: var(--font-sans); } 2670 .timeline-card h4 { margin: 0 0 0.3rem 0; font-size: 0.95rem; } 2671 .timeline-card p { font-size: 0.85rem; color: var(--text-muted); margin: 0; line-height: 1.6; } 2672 .timeline-card .card-meta { font-size: 0.75rem; color: var(--text-muted); font-family: var(--font-sans); margin-top: 0.3rem; } 2673 """ + '</style>' 2674 2675 # Build timeline HTML 2676 timeline_html = '' 2677 for year in sorted(by_year.keys()): 2678 year_events = by_year[year] 2679 cards = '' 2680 for e in year_events: 2681 (yr, yr_end, etype, title, desc, cat, medium, loc, conf, ms) = e 2682 cat = cat or '' 2683 color = cat_colors.get(cat, '#6b7280') 2684 label = cat_labels.get(cat, etype or 'Other') 2685 badge = f'<span class="card-type-badge" style="background:{color}">{escape(label)}</span>' 2686 meta_parts = [] 2687 if medium: 2688 meta_parts.append(escape(medium)) 2689 if loc: 2690 meta_parts.append(escape(loc)) 2691 if conf and conf != 'HIGH': 2692 meta_parts.append(f'Confidence: {escape(conf)}') 2693 meta = f'<div class="card-meta">{" | ".join(meta_parts)}</div>' if meta_parts else '' 2694 2695 cards += f""" 2696 <div class="timeline-card" data-category="{escape(cat)}"> 2697 {badge} 2698 <h4>{escape(title)}</h4> 2699 <p>{escape(desc or '')}</p> 2700 {meta} 2701 </div>""" 2702 2703 year_label = f"{year}" if not year_events[0][1] else f"{year}-{year_events[0][1]}" 2704 timeline_html += f""" 2705 <div class="timeline-year"> 2706 <div class="year-marker">{year_label}</div> 2707 {cards} 2708 </div>""" 2709 2710 # Filters 2711 categories = sorted(set(e[5] or '' for e in events)) 2712 filter_html = '' 2713 for cat in categories: 2714 label = cat_labels.get(cat, cat or 'Other') 2715 color = cat_colors.get(cat, '#6b7280') 2716 filter_html += f'<label><input type="checkbox" checked data-filter="{escape(cat)}"> <span style="color:{color}">{escape(label)}</span></label>' 2717 2718 filter_js = """ 2719 <script> 2720 document.querySelectorAll('.timeline-filters input').forEach(cb => { 2721 cb.addEventListener('change', () => { 2722 const cat = cb.dataset.filter; 2723 const show = cb.checked; 2724 document.querySelectorAll(`.timeline-card[data-category="${cat}"]`).forEach(card => { 2725 card.style.display = show ? '' : 'none'; 2726 }); 2727 // Hide empty year markers 2728 document.querySelectorAll('.timeline-year').forEach(yr => { 2729 const visible = yr.querySelectorAll('.timeline-card:not([style*="display: none"])'); 2730 yr.style.display = visible.length ? '' : 'none'; 2731 }); 2732 }); 2733 }); 2734 </script>""" 2735 2736 body = f""" 2737 <div class="timeline-page"> 2738 <h2>Timeline of the <em>Hypnerotomachia Poliphili</em></h2> 2739 <p>A chronological view of the HP's five-century reception: editions, translations, 2740 annotations, scholarship, and art inspired by the book. {len(events)} events spanning 2741 {min(by_year.keys())}–{max(by_year.keys())}.</p> 2742 <p style="font-size:0.85rem; color:var(--text-muted)">Filter by category:</p> 2743 <div class="timeline-filters">{filter_html}</div> 2744 <div class="timeline">{timeline_html}</div> 2745 </div>""" 2746 2747 page = page_shell('Timeline', body, active_nav='timeline', 2748 extra_css=timeline_css, extra_js=filter_js) 2749 (SITE_DIR / 'timeline.html').write_text(page, encoding='utf-8') 2750 print(f" timeline.html ({len(events)} events)") 2751 2752 2753# ============================================================ 2754# Manuscripts pages 2755# ============================================================ 2756 2757def build_manuscripts_pages(conn): 2758 """Generate manuscripts/index.html and manuscripts/*.html from hp_copies.""" 2759 cur = conn.cursor() 2760 manu_dir = SITE_DIR / 'manuscripts' 2761 manu_dir.mkdir(exist_ok=True) 2762 2763 # Get all copies 2764 cur.execute(""" 2765 SELECT id, shelfmark, institution, city, country, edition, 2766 has_annotations, studied_by, annotation_summary, 2767 hand_count, copy_notes, has_images_in_project, 2768 confidence, review_status 2769 FROM hp_copies ORDER BY edition, shelfmark 2770 """) 2771 copies = cur.fetchall() 2772 2773 # Get hands per copy 2774 cur.execute(""" 2775 SELECT manuscript_shelfmark, hand_label, attribution, is_alchemist, school, description 2776 FROM annotator_hands ORDER BY manuscript_shelfmark, hand_label 2777 """) 2778 hands_by_ms = {} 2779 for row in cur.fetchall(): 2780 hands_by_ms.setdefault(row[0], []).append(row) 2781 2782 # Get ref counts per copy (uses dissertation_refs because 51 annotations 2783 # lack manuscript_id linkage — TODO: backfill manuscript_id in annotations) 2784 cur.execute(""" 2785 SELECT manuscript_shelfmark, COUNT(*) FROM dissertation_refs 2786 WHERE manuscript_shelfmark IS NOT NULL 2787 GROUP BY manuscript_shelfmark 2788 """) 2789 refs_by_ms = {row[0]: row[1] for row in cur.fetchall()} 2790 2791 # Get match counts per copy 2792 cur.execute(""" 2793 SELECT m.shelfmark, mat.confidence, COUNT(*) 2794 FROM matches mat 2795 JOIN images i ON mat.image_id = i.id 2796 JOIN manuscripts m ON i.manuscript_id = m.id 2797 GROUP BY m.shelfmark, mat.confidence 2798 """) 2799 matches_by_ms = {} 2800 for row in cur.fetchall(): 2801 matches_by_ms.setdefault(row[0], {})[row[1]] = row[2] 2802 2803 manu_css = '<style>' + """ 2804 .manuscripts-page { max-width: 900px; margin: 2rem auto; padding: 0 2rem; } 2805 .manuscripts-page h2 { color: var(--accent); border-bottom: 1px solid var(--border); padding-bottom: 0.3rem; } 2806 .manuscripts-page h3 { margin-top: 2rem; } 2807 .manuscripts-page p { line-height: 1.8; } 2808 .copy-card { padding: 1.5rem; margin-bottom: 1.5rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: 4px; } 2809 .copy-card h3 { margin-top: 0; color: var(--accent); } 2810 .copy-card .copy-meta { font-size: 0.85rem; color: var(--text-muted); font-family: var(--font-sans); margin-bottom: 0.5rem; } 2811 .copy-card .copy-notes { font-size: 0.9rem; line-height: 1.7; } 2812 .copy-detail { max-width: 850px; margin: 2rem auto; padding: 0 2rem; } 2813 .copy-detail h2 { color: var(--accent); } 2814 .copy-detail h3 { margin-top: 1.5rem; border-bottom: 1px solid var(--border); padding-bottom: 0.2rem; } 2815 .copy-detail p { line-height: 1.8; } 2816 .copy-detail table { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 0.85rem; } 2817 .copy-detail th, .copy-detail td { padding: 0.5rem 0.75rem; border: 1px solid var(--border); text-align: left; } 2818 .copy-detail th { background: var(--bg); font-weight: 600; } 2819 .copy-detail .provisional { background: #fff3cd; padding: 0.5rem 1rem; border-left: 3px solid #ffc107; margin: 1rem 0; font-size: 0.9rem; } 2820 .copy-detail .cross-links { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--border); } 2821 .copy-detail .cross-links a { display: inline-block; margin: 0.2rem 0.3rem; padding: 0.2rem 0.6rem; background: var(--bg); border: 1px solid var(--border); border-radius: 3px; font-size: 0.85rem; color: var(--text); text-decoration: none; } 2822 .copy-detail .cross-links a:hover { border-color: var(--accent); color: var(--accent); } 2823 """ + '</style>' 2824 2825 # Build detail pages 2826 annotated_cards = '' 2827 for copy in copies: 2828 (cid, shelfmark, inst, city, country, edition, has_annot, 2829 studied_by, annot_summary, hand_count, notes, 2830 has_images, confidence, status) = copy 2831 2832 slug = slugify(shelfmark) 2833 hands = hands_by_ms.get(shelfmark, []) 2834 ref_count = refs_by_ms.get(shelfmark, 0) 2835 match_data = matches_by_ms.get(shelfmark, {}) 2836 2837 # Confidence badge 2838 conf_badge = confidence_badge_html(confidence) if confidence else '' 2839 2840 # Card for index 2841 meta_parts = [f'{escape(inst)}, {escape(city or "")}'] 2842 if edition: 2843 meta_parts.append(f'{edition} edition') 2844 if hand_count: 2845 meta_parts.append(f'{hand_count} annotator hand{"s" if hand_count != 1 else ""}') 2846 if ref_count: 2847 meta_parts.append(f'{ref_count} dissertation refs') 2848 2849 annotated_cards += f""" 2850 <div class="copy-card"> 2851 <h3><a href="{slug}.html">{escape(shelfmark)}</a> {conf_badge}</h3> 2852 <div class="copy-meta">{" | ".join(meta_parts)}</div> 2853 <div class="copy-notes">{escape(notes or '')}</div> 2854 </div>""" 2855 2856 # Detail page 2857 # Hands table 2858 hands_html = '' 2859 if hands: 2860 rows = '' 2861 for h in hands: 2862 alch = ' (alchemist)' if h[3] else '' 2863 school = f' [{escape(h[4])}]' if h[4] else '' 2864 rows += f'<tr><td>{escape(h[1])}</td><td>{escape(h[2] or "Anonymous")}{alch}{school}</td><td>{escape((h[5] or "")[:200])}</td></tr>' 2865 hands_html = f""" 2866 <h3>Annotator Hands</h3> 2867 <table> 2868 <thead><tr><th>Hand</th><th>Attribution</th><th>Description</th></tr></thead> 2869 <tbody>{rows}</tbody> 2870 </table>""" 2871 2872 # Match stats 2873 match_html = '' 2874 if match_data: 2875 total = sum(match_data.values()) 2876 match_html = f""" 2877 <h3>Image Matches</h3> 2878 <p>{total} images matched to dissertation references.</p> 2879 <ul> 2880 <li>HIGH confidence: {match_data.get('HIGH', 0)}</li> 2881 <li>MEDIUM confidence: {match_data.get('MEDIUM', 0)}</li> 2882 <li>LOW confidence: {match_data.get('LOW', 0)}</li> 2883 </ul>""" 2884 2885 # Provisional warning for BL 2886 prov_html = '' 2887 if confidence == 'LOW': 2888 prov_html = '<div class="provisional">All image matches for this copy are LOW confidence. The photograph numbering does not directly encode folio information. Manual verification is required.</div>' 2889 2890 # Cross links 2891 cross_links = ['<a href="../dictionary/annotator-hand.html">Annotator Hand</a>', 2892 '<a href="../dictionary/marginalia.html">Marginalia</a>', 2893 '<a href="../concordance-method.html">Concordance Method</a>'] 2894 if any(h[3] for h in hands): 2895 cross_links.append('<a href="../russell-alchemical-hands.html">Alchemical Hands Essay</a>') 2896 cross_links.append('<a href="../dictionary/alchemical-allegory.html">Alchemical Allegory</a>') 2897 2898 detail_body = f""" 2899 <div class="copy-detail"> 2900 <p><a href="index.html">← All Manuscripts</a></p> 2901 <h2>{escape(shelfmark)} {conf_badge}</h2> 2902 <p><strong>{escape(inst)}</strong>, {escape(city or '')} ({escape(country or '')})</p> 2903 <p>Edition: {escape(edition or 'unknown')} | Studied by: {escape(studied_by or 'not studied')} | 2904 Dissertation references: {ref_count} | Images in project: {'Yes' if has_images else 'No'}</p> 2905 <h3>Description</h3> 2906 <p>{escape(notes or 'No description available.')}</p> 2907 {hands_html} 2908 {match_html} 2909 {prov_html} 2910 <div class="cross-links"> 2911 <h4>Related Pages</h4> 2912 {''.join(cross_links)} 2913 </div> 2914 </div>""" 2915 2916 detail_page = page_shell(shelfmark, detail_body, active_nav='manuscripts', 2917 extra_css=manu_css, depth=1) 2918 (manu_dir / f'{slug}.html').write_text(detail_page, encoding='utf-8') 2919 2920 # Index page 2921 index_body = f""" 2922 <div class="manuscripts-page"> 2923 <h2>Manuscripts of the <em>Hypnerotomachia Poliphili</em></h2> 2924 <p>The 1499 <em>Hypnerotomachia Poliphili</em> survives in approximately 200 copies 2925 worldwide. Russell's PhD thesis (2014) studied six annotated copies in detail, 2926 identifying eleven distinct annotator hands. This section presents those copies 2927 with their annotation profiles, hand attributions, and links to the project's 2928 marginalia and concordance data.</p> 2929 <p>Each copy page shows the annotator hands identified by Russell, the number of 2930 dissertation references attributed to that copy, and any matched images. Where 2931 matching confidence is low, that status is marked explicitly.</p> 2932 2933 <h3>Annotated Copies Studied by Russell (2014)</h3> 2934 {annotated_cards} 2935 </div>""" 2936 2937 index_page = page_shell('Manuscripts', index_body, active_nav='manuscripts', 2938 extra_css=manu_css, depth=1) 2939 (manu_dir / 'index.html').write_text(index_page, encoding='utf-8') 2940 print(f" manuscripts/index.html + {len(copies)} copy pages") 2941 2942 2943# ============================================================ 2944# Woodcuts pages 2945# ============================================================ 2946 2947def build_woodcuts_pages(conn): 2948 """Generate woodcuts/index.html and woodcuts/*.html — 1499 edition gallery. 2949 2950 Sources woodcut images from Internet Archive facsimile (A336080v1, 2951 University of Seville copy, 600ppi). Falls back to BL photographs 2952 where IA images are not yet cached. 2953 """ 2954 cur = conn.cursor() 2955 wc_dir = SITE_DIR / 'woodcuts' 2956 wc_dir.mkdir(exist_ok=True) 2957 ia_img_dir = SITE_DIR / 'images' / 'woodcuts_1499' 2958 2959 # Clean up stale pages from previous builds 2960 for old_file in wc_dir.glob('*.html'): 2961 if old_file.stem != 'index': 2962 old_file.unlink() 2963 2964 # Which signatures have marginalia pages? 2965 marg_dir = SITE_DIR / 'marginalia' 2966 _wc_marg_sigs = set() 2967 if marg_dir.exists(): 2968 for f in marg_dir.glob('*.html'): 2969 if f.stem != 'index': 2970 _wc_marg_sigs.add(f.stem) 2971 2972 # Which dictionary terms exist? 2973 dict_dir = SITE_DIR / 'dictionary' 2974 _wc_dict_slugs = set() 2975 if dict_dir.exists(): 2976 for f in dict_dir.glob('*.html'): 2977 if f.stem != 'index': 2978 _wc_dict_slugs.add(f.stem) 2979 2980 cur.execute(""" 2981 SELECT id, slug, title, signature_1499, page_1499, page_1499_ia, 2982 bl_photo_number, subject_category, woodcut_type, 2983 description, narrative_context, chapter_context, 2984 depicted_elements, has_annotation, alchemical_annotation, 2985 annotation_density, dictionary_terms, scholarly_discussion, 2986 influence, source_method, confidence, notes, 2987 ia_image_cached 2988 FROM woodcuts 2989 WHERE page_1499 IS NOT NULL 2990 AND source_method != 'CORPUS_EXTRACTION' 2991 ORDER BY page_1499 2992 """) 2993 woodcuts = cur.fetchall() 2994 2995 if not woodcuts: 2996 print(" woodcuts: no data") 2997 return 2998 2999 cat_colors = { 3000 'ARCHITECTURAL': '#8b5cf6', 'LANDSCAPE': '#10b981', 'NARRATIVE': '#3b82f6', 3001 'HIEROGLYPHIC': '#f59e0b', 'PROCESSION': '#ef4444', 'DECORATIVE': '#6366f1', 3002 'PORTRAIT': '#ec4899', 'DIAGRAM': '#14b8a6', 3003 } 3004 3005 wc_css = '<style>' + """ 3006 .woodcuts-page { max-width: 1200px; margin: 2rem auto; padding: 0 2rem; } 3007 .woodcuts-page h1 { color: var(--accent); font-size: 1.8rem; margin-bottom: 0.3rem; } 3008 .woodcuts-page .intro { max-width: 800px; line-height: 1.8; margin-bottom: 1.5rem; color: var(--text-muted); } 3009 .wc-filters { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 1.5rem; } 3010 .wc-filter-btn { padding: 0.3rem 0.7rem; border: 1px solid var(--border); border-radius: 3px; 3011 font-size: 0.75rem; font-weight: 600; text-transform: uppercase; cursor: pointer; 3012 background: var(--bg); color: var(--text-muted); transition: all 0.15s; } 3013 .wc-filter-btn:hover, .wc-filter-btn.active { background: var(--accent); color: white; border-color: var(--accent); } 3014 .woodcut-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 1.5rem; } 3015 .woodcut-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px; 3016 overflow: hidden; transition: box-shadow 0.2s, transform 0.2s; } 3017 .woodcut-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.1); transform: translateY(-2px); } 3018 .woodcut-card img { width: 100%; height: 320px; object-fit: cover; object-position: top; 3019 border-bottom: 1px solid var(--border); background: #f5f0e8; } 3020 .woodcut-card .wc-info { padding: 0.75rem 1rem; } 3021 .woodcut-card h4 { margin: 0 0 0.3rem; font-size: 0.95rem; } 3022 .woodcut-card h4 a { color: var(--text); text-decoration: none; } 3023 .woodcut-card h4 a:hover { color: var(--accent); } 3024 .woodcut-card .wc-meta { font-size: 0.75rem; color: var(--text-muted); font-family: var(--font-sans); } 3025 .woodcut-card .wc-desc { font-size: 0.82rem; color: var(--text-muted); margin: 0.3rem 0 0; line-height: 1.5; } 3026 .wc-cat-badge { display: inline-block; padding: 0.15rem 0.45rem; border-radius: 2px; 3027 font-size: 0.65rem; font-weight: 600; text-transform: uppercase; color: white; margin-right: 0.3rem; } 3028 .woodcut-detail { max-width: 900px; margin: 2rem auto; padding: 0 2rem; } 3029 .woodcut-detail h2 { color: var(--accent); margin-bottom: 0.5rem; } 3030 .woodcut-detail h3 { margin-top: 1.5rem; border-bottom: 1px solid var(--border); padding-bottom: 0.2rem; } 3031 .woodcut-detail p { line-height: 1.8; } 3032 .woodcut-detail .wc-image-frame { text-align: center; margin: 1.5rem 0; } 3033 .woodcut-detail .wc-image-frame img { max-width: 100%; max-height: 700px; border: 1px solid var(--border); 3034 box-shadow: 0 2px 8px rgba(0,0,0,0.1); background: #f5f0e8; } 3035 .woodcut-detail .wc-image-caption { font-size: 0.8rem; color: var(--text-muted); margin-top: 0.5rem; font-style: italic; } 3036 .woodcut-detail .cross-links { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--border); } 3037 .woodcut-detail .cross-links a { display: inline-block; margin: 0.2rem 0.3rem; padding: 0.2rem 0.6rem; 3038 background: var(--bg); border: 1px solid var(--border); border-radius: 3px; 3039 font-size: 0.85rem; color: var(--text); text-decoration: none; } 3040 .woodcut-detail .cross-links a:hover { border-color: var(--accent); color: var(--accent); } 3041 .wc-nav-strip { display: flex; justify-content: space-between; margin: 1.5rem 0; font-size: 0.85rem; } 3042 .wc-nav-strip a { color: var(--accent); text-decoration: none; } 3043 .wc-annotation-note { background: var(--bg-card); border-left: 3px solid var(--accent); 3044 padding: 0.75rem 1rem; margin: 1rem 0; font-size: 0.9rem; } 3045 @media (max-width: 768px) { 3046 .woodcut-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); } 3047 .woodcut-card img { height: 240px; } 3048 } 3049 """ + '</style>' 3050 3051 # Build detail pages and collect card HTML 3052 cards_html = '' 3053 slugs = [] # For prev/next navigation 3054 3055 for wc in woodcuts: 3056 (wid, slug, title, sig, page, ia_page, photo, cat, wtype, 3057 desc, narrative, context, elements, has_ann, has_alch, 3058 ann_density, dict_terms, scholarly, influence, source, 3059 conf, notes, ia_cached) = wc 3060 slugs.append((slug, title, page)) 3061 3062 for idx, wc in enumerate(woodcuts): 3063 (wid, slug, title, sig, page, ia_page, photo, cat, wtype, 3064 desc, narrative, context, elements, has_ann, has_alch, 3065 ann_density, dict_terms, scholarly, influence, source, 3066 conf, notes, ia_cached) = wc 3067 3068 color = cat_colors.get(cat, '#6b7280') 3069 cat_badge = f'<span class="wc-cat-badge" style="background:{color}">{escape(cat or "")}</span>' 3070 alch_badge = ' <span class="alchemist-tag">Alchemist</span>' if has_alch else '' 3071 3072 # Determine image path 3073 img_filename = f'hp1499_p{page:03d}.jpg' 3074 img_exists = (ia_img_dir / img_filename).exists() 3075 img_path = f'../images/woodcuts_1499/{img_filename}' if img_exists else '' 3076 3077 # Card for index 3078 img_tag = f'<img src="../images/woodcuts_1499/{img_filename}" alt="{escape(title)}" loading="lazy">' if img_exists else '<div style="height:320px;background:#e8e0d0;display:flex;align-items:center;justify-content:center;color:#999;font-style:italic">Image pending</div>' 3079 cards_html += f""" 3080 <div class="woodcut-card" data-category="{escape(cat or '')}"> 3081 <a href="{slug}.html">{img_tag}</a> 3082 <div class="wc-info"> 3083 <h4><a href="{slug}.html">{escape(title)}</a></h4> 3084 <div class="wc-meta">{cat_badge} p.{page}{alch_badge}</div> 3085 <p class="wc-desc">{escape((desc or '')[:120])}{'...' if desc and len(desc) > 120 else ''}</p> 3086 </div> 3087 </div>""" 3088 3089 # Detail page 3090 # Image 3091 if img_exists: 3092 img_html = f""" 3093 <div class="wc-image-frame"> 3094 <img src="../images/woodcuts_1499/{img_filename}" alt="{escape(title)}"> 3095 <div class="wc-image-caption">From the 1499 Aldine first edition (University of Seville copy, via Internet Archive)</div> 3096 </div>""" 3097 else: 3098 img_html = '' 3099 3100 # Narrative context (prefer narrative_context, fall back to chapter_context) 3101 narr = narrative or context or '' 3102 narr_html = f'<h3>In the Narrative</h3><p>{escape(narr)}</p>' if narr else '' 3103 3104 desc_html = f'<p>{escape(desc or "")}</p>' if desc else '' 3105 scholarly_html = f'<h3>In Scholarship</h3><p>{escape(scholarly or "")}</p>' if scholarly else '' 3106 influence_html = f'<h3>Influence & Reception</h3><p>{escape(influence or "")}</p>' if influence else '' 3107 3108 # Annotation note (if this page has BL annotations) 3109 ann_html = '' 3110 if has_ann or has_alch: 3111 ann_parts = [] 3112 if has_alch: 3113 ann_parts.append('alchemical annotations by Hand B') 3114 if ann_density: 3115 ann_parts.append(f'{ann_density.lower()} annotation density') 3116 if photo: 3117 ann_parts.append(f'BL photograph #{photo}') 3118 folio_link = f' <a href="../marginalia/{(sig or "").lower()}.html">View folio →</a>' if (sig or '').lower() in _wc_marg_sigs else '' 3119 ann_html = f'<div class="wc-annotation-note"><strong>In the BL copy (C.60.o.12):</strong> This page has {", ".join(ann_parts)}.{folio_link}</div>' 3120 3121 # Cross links 3122 cross_html = '' 3123 cross_parts = [] 3124 if dict_terms: 3125 term_links = ''.join(f'<a href="../dictionary/{t.strip()}.html">{t.strip().replace("-", " ").title()}</a>' 3126 for t in dict_terms.split(',') if t.strip() and t.strip() in _wc_dict_slugs) 3127 if term_links: 3128 cross_parts.append(f'<h4>Related Dictionary Terms</h4>{term_links}') 3129 if sig and sig.lower() in _wc_marg_sigs: 3130 cross_parts.append(f'<h4>Related Folio</h4><a href="../marginalia/{sig.lower()}.html">Folio {escape(sig)}</a>') 3131 if cross_parts: 3132 cross_html = f'<div class="cross-links">{"".join(cross_parts)}</div>' 3133 3134 # Prev/next navigation 3135 prev_link = f'<a href="{slugs[idx-1][0]}.html">← {escape(slugs[idx-1][1][:30])}</a>' if idx > 0 else '<span></span>' 3136 next_link = f'<a href="{slugs[idx+1][0]}.html">{escape(slugs[idx+1][1][:30])} →</a>' if idx < len(slugs) - 1 else '<span></span>' 3137 3138 source_label = source or 'unknown' 3139 detail_body = f""" 3140 <div class="woodcut-detail"> 3141 <p><a href="index.html">← All Woodcuts</a></p> 3142 <h2>{escape(title)}</h2> 3143 <div style="margin-bottom:1rem">{cat_badge} {escape(sig or '')} · p.{page}</div> 3144 {img_html} 3145 {desc_html} 3146 {narr_html} 3147 {ann_html} 3148 {scholarly_html} 3149 {influence_html} 3150 {cross_html} 3151 <div class="wc-nav-strip">{prev_link}{next_link}</div> 3152 <p style="font-size:0.8rem; color:var(--text-muted); border-top:1px solid var(--border); padding-top:0.5rem; margin-top:1rem"> 3153 Source: {escape(source_label)} · {escape(conf or 'DRAFT')}</p> 3154 </div>""" 3155 3156 detail_page = page_shell(title, detail_body, active_nav='woodcuts', 3157 extra_css=wc_css, depth=1) 3158 (wc_dir / f'{slug}.html').write_text(detail_page, encoding='utf-8') 3159 3160 # Count categories for filter buttons 3161 cat_counts = {} 3162 for wc in woodcuts: 3163 cat = wc[7] or 'UNCATEGORIZED' 3164 cat_counts[cat] = cat_counts.get(cat, 0) + 1 3165 3166 filter_buttons = '<button class="wc-filter-btn active" onclick="filterWoodcuts(\'ALL\')">All</button>' 3167 for cat in sorted(cat_counts.keys()): 3168 color = cat_colors.get(cat, '#6b7280') 3169 filter_buttons += f'<button class="wc-filter-btn" onclick="filterWoodcuts(\'{cat}\')" style="border-color:{color}">{cat} ({cat_counts[cat]})</button>' 3170 3171 filter_js = """ 3172 <script> 3173 function filterWoodcuts(cat) { 3174 document.querySelectorAll('.wc-filter-btn').forEach(b => b.classList.remove('active')); 3175 event.target.classList.add('active'); 3176 document.querySelectorAll('.woodcut-card').forEach(card => { 3177 if (cat === 'ALL' || card.dataset.category === cat) { 3178 card.style.display = ''; 3179 } else { 3180 card.style.display = 'none'; 3181 } 3182 }); 3183 } 3184 </script>""" 3185 3186 with_images = sum(1 for wc in woodcuts if (ia_img_dir / f'hp1499_p{wc[4]:03d}.jpg').exists()) 3187 3188 index_body = f""" 3189 <div class="woodcuts-page"> 3190 <h1>Woodcuts of the <em>Hypnerotomachia Poliphili</em></h1> 3191 <p class="intro">The 1499 Aldine first edition contains approximately 172 woodcut illustrations — 3192 the most ambitious visual program of any incunabulum. Their designer remains unidentified, 3193 though Benedetto Bordon is the most widely proposed candidate. The woodcuts integrate text and 3194 image with unprecedented sophistication, each composition calibrated to its surrounding typography. 3195 Images are from the 1499 first edition (University of Seville copy, via Internet Archive).</p> 3196 3197 <div class="wc-filters">{filter_buttons}</div> 3198 3199 <p style="font-size:0.8rem; color:var(--text-muted); margin-bottom:1rem"> 3200 Showing {len(woodcuts)} woodcuts · {with_images} with facsimile images</p> 3201 3202 <div class="woodcut-grid">{cards_html}</div> 3203 </div>""" 3204 3205 index_page = page_shell('Woodcuts — Hypnerotomachia Poliphili', index_body, 3206 active_nav='woodcuts', extra_css=wc_css, 3207 extra_js=filter_js, depth=1) 3208 (wc_dir / 'index.html').write_text(index_page, encoding='utf-8') 3209 print(f" woodcuts/index.html + {len(woodcuts)} woodcut pages ({with_images} with images)") 3210 3211 3212# ============================================================ 3213# "The Book" page — narrative summary of the HP 3214# ============================================================ 3215 3216def build_the_book_page(): 3217 """Generate the-book.html: a narrative walkthrough of the HP for non-specialists.""" 3218 3219 book_css = '<style>' + """ 3220 .book-page { max-width: 850px; margin: 2rem auto; padding: 0 2rem; } 3221 .book-page h2 { color: var(--accent); margin: 2rem 0 0.5rem; border-bottom: 1px solid var(--border); padding-bottom: 0.3rem; } 3222 .book-page h3 { margin: 1.5rem 0 0.5rem; } 3223 .book-page p { line-height: 1.9; margin-bottom: 1rem; } 3224 .book-page .book-note { background: var(--bg-card); padding: 0.75rem 1rem; border-left: 3px solid var(--accent); margin: 1rem 0; font-size: 0.9rem; color: var(--text-muted); } 3225 .book-page .cross-links { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--border); } 3226 .book-page .cross-links a { display: inline-block; margin: 0.2rem 0.3rem; padding: 0.2rem 0.6rem; background: var(--bg); border: 1px solid var(--border); border-radius: 3px; font-size: 0.85rem; color: var(--text); text-decoration: none; } 3227 .book-page .cross-links a:hover { border-color: var(--accent); color: var(--accent); } 3228 """ + '</style>' 3229 3230 body = """ 3231 <div class="book-page"> 3232 <h1>The Book</h1> 3233 3234 <p class="book-note">This page summarizes the narrative of the <em>Hypnerotomachia 3235 Poliphili</em> for readers who have not encountered it before. It is not a scholarly 3236 analysis but a walkthrough — a map of the journey Poliphilo takes through 3237 his dream, with pointers to the woodcuts, places, and characters you will encounter 3238 on other pages of this site.</p> 3239 3240 <h2>What the <em>Hypnerotomachia</em> Is</h2> 3241 3242 <p>The <em>Hypnerotomachia Poliphili</em> — the title means roughly 3243 "Poliphilo's struggle of love in a dream" — was published in Venice in 1499 3244 by <a href="dictionary/aldus-manutius.html">Aldus Manutius</a>, the most 3245 celebrated printer of the Renaissance. It is a large folio volume of 234 leaves, 3246 illustrated with <a href="woodcuts/index.html">172 woodcut illustrations</a> 3247 of extraordinary refinement. The text is written in a 3248 <a href="dictionary/macaronic-language.html">macaronic language</a> that mixes 3249 Italian syntax with Latin vocabulary, Greek loanwords, and invented words — 3250 a hybrid tongue that has baffled and delighted readers for five centuries.</p> 3251 3252 <p>The book tells the story of a young man named 3253 <a href="dictionary/poliphilo.html">Poliphilo</a> who falls asleep and dreams. 3254 In his dream, he journeys through ruined classical buildings, elaborate gardens, 3255 and allegorical landscapes in search of his beloved, 3256 <a href="dictionary/polia.html">Polia</a>. Along the way he encounters 3257 queens, nymphs, processions, sacrifices, inscriptions, and monuments — 3258 each described in painstaking visual and architectural detail. The dream is 3259 also a dream within a dream: partway through his journey, Poliphilo falls 3260 asleep again and enters a deeper vision.</p> 3261 3262 <p>The author's identity is concealed in an <a href="dictionary/acrostic.html">acrostic</a> 3263 formed by the chapter initials: POLIAM FRATER FRANCISCVS COLVMNA PERAMAVIT 3264 ("Brother Francesco Colonna loved Polia greatly"). Which Francesco Colonna — 3265 a Venetian Dominican friar, a Roman nobleman, or someone else entirely — 3266 remains the subject of a long-running 3267 <a href="dictionary/authorship-debate.html">authorship debate</a>.</p> 3268 3269 <h2>The Journey</h2> 3270 3271 <h3>The Dark Forest</h3> 3272 <p>The narrative opens with Poliphilo lost in a 3273 <a href="dictionary/dark-forest.html">dark, terrifying forest</a> — a 3274 deliberate echo of Dante's <em>selva oscura</em> at the beginning of the 3275 <em>Inferno</em>. He is alone, frightened, and disoriented. The 3276 <a href="russell-alchemical-hands.html#annotated-woodcuts">woodcut of the dark forest</a> establishes 3277 the visual register that will govern the entire book: dense, precise, and 3278 atmospheric.</p> 3279 3280 <p>Poliphilo emerges from the forest into an open landscape and, exhausted, 3281 falls asleep — entering the 3282 <a href="dictionary/dream-within-dream.html">dream within the dream</a> 3283 that contains the main narrative.</p> 3284 3285 <h3>The Ruins and the Pyramid</h3> 3286 <p>In his inner dream, Poliphilo encounters a vast ruined classical complex: 3287 a <a href="dictionary/ruined-temple.html">great temple</a>, a colossal 3288 <a href="dictionary/pyramid.html">pyramid</a> surmounted by an 3289 <a href="dictionary/obelisk.html">obelisk</a>, and monumental gates decorated 3290 with reliefs and inscriptions. He describes their 3291 <a href="dictionary/column-orders.html">column orders</a>, 3292 proportions, and materials with the precision of an architectural treatise. 3293 This is where the book's reputation as an encyclopedia of classical design 3294 originates: the ruins are not mere scenery but occasions for sustained 3295 <a href="dictionary/ekphrasis.html">ekphrasis</a> — verbal descriptions 3296 so vivid they rival the woodcut illustrations.</p> 3297 3298 <p>The most famous image in the book appears here: the 3299 <a href="russell-alchemical-hands.html#annotated-woodcuts">elephant bearing an obelisk</a> 3300 (signature b6v), decorated with pseudo-<a href="dictionary/hieroglyph.html">hieroglyphic</a> 3301 carvings. This image later inspired Bernini's 1667 sculpture in the Piazza della 3302 Minerva in Rome, commissioned by Pope Alexander VII — who himself annotated 3303 his own copy of the HP.</p> 3304 3305 <h3>Queen Eleuterylida and the Three Gates</h3> 3306 <p>Poliphilo arrives at the court of <a href="dictionary/eleuterylida.html">Queen 3307 Eleuterylida</a>, whose name derives from the Greek word for freedom. 3308 At the <a href="dictionary/thelemia.html">gate of Thelemia</a> (free will), 3309 he must choose among three doors representing three ways of life: pleasure, 3310 action, and contemplation. He chooses the middle path.</p> 3311 3312 <p>The queen's court introduces the 3313 <a href="dictionary/nymphs-five-senses.html">five nymphs</a> who represent 3314 the bodily senses: sight, hearing, smell, taste, and touch. They will guide 3315 Poliphilo through the next stage of his journey.</p> 3316 3317 <h3>The Nymphs and the Bath</h3> 3318 <p>The five nymphs bathe Poliphilo in elaborate 3319 <a href="dictionary/bath-thermae.html">classical thermae</a>, dress him in fine 3320 garments, and lead him through the queen's palace. These passages combine 3321 sensory education with erotic initiation — the nymphs awaken Poliphilo's 3322 body and mind simultaneously. The architecture of the bath is described with 3323 attention to water systems, marble surfaces, and spatial arrangement, embodying 3324 what Liane Lefaivre called the 3325 <a href="dictionary/architectural-body.html">"architectural body"</a>: 3326 architecture experienced through the flesh, not through abstract geometry.</p> 3327 3328 <h3>The Triumphal Processions</h3> 3329 <p>Poliphilo witnesses a series of elaborate 3330 <a href="dictionary/triumphal-procession.html">triumphal processions</a> 3331 featuring chariots drawn by exotic animals, musicians, dancers, and allegorical 3332 personifications. The <a href="russell-alchemical-hands.html#annotated-woodcuts">procession 3333 woodcuts</a> are among the most complex in the book. These passages connect the HP 3334 to Renaissance festival culture and to the classical literary tradition of the 3335 <em>triumphus</em>. The alchemical annotators later read them as encoding stages 3336 of the <a href="dictionary/great-work.html">Great Work</a> of transmutation.</p> 3337 3338 <h3>The Sacrifice to Priapus</h3> 3339 <p>One of the HP's most explicitly pagan scenes: a 3340 <a href="dictionary/sacrifice-priapus.html">ritual sacrifice</a> at the altar 3341 of Priapus, the garden god of fertility. A donkey is offered in a ceremony that 3342 combines classical sacrificial practice with frank fertility symbolism. Scholarship 3343 has called this "perhaps the most censored woodcut of the Renaissance."</p> 3344 3345 <h3>The Voyage to Cythera</h3> 3346 <p>Poliphilo and Polia travel by boat to 3347 <a href="dictionary/cythera.html">Cythera</a>, the island sacred to 3348 <a href="dictionary/venus-aphrodite.html">Venus</a>. The sea crossing represents 3349 the passage from earthly desire to divine love. On the island, they encounter 3350 an elaborate <a href="dictionary/circular-garden.html">circular garden</a> with 3351 concentric rings of planting surrounding Venus's temple — a cosmological 3352 design radiating from the goddess at the center outward through successive levels 3353 of material existence.</p> 3354 3355 <p>At the temple, <a href="dictionary/cupid-eros.html">Cupid</a> presides over 3356 the union of Poliphilo and Polia. The consummation of their love is the narrative 3357 climax of Book I.</p> 3358 3359 <h3>Book II: Polia Speaks</h3> 3360 <p>In a remarkable structural move, the HP gives Polia her own voice. Book II 3361 is narrated by Polia herself, who tells the story of her initial rejection of 3362 Poliphilo, her resistance to love, and her eventual conversion by Venus. This 3363 reframing of the love story from the beloved's perspective makes the HP unusual 3364 among Renaissance love narratives, which typically silence the beloved or reduce 3365 her to a visual object.</p> 3366 3367 <h3>The Awakening</h3> 3368 <p>Poliphilo wakes. Polia dissolves. The dream ends. The reader is returned 3369 to the waking world that framed the narrative from the beginning. What remains 3370 is the book itself — the woodcuts, the language, the architecture, the 3371 gardens — and five centuries of readers who have left their marks in its 3372 margins.</p> 3373 3374 <h2>The Readers</h2> 3375 <p>James Russell's 2014 PhD thesis documented the 3376 <a href="dictionary/marginalia.html">marginalia</a> in six copies of the HP, 3377 identifying eleven distinct <a href="dictionary/annotator-hand.html">annotator 3378 hands</a>. What he found overturns the idea that the HP was an unread curiosity. 3379 The Giovio brothers read it as a botanical compendium. Ben Jonson mined it for 3380 stage design imagery. Pope Alexander VII collected examples of verbal 3381 <a href="dictionary/acutezze.html">wit</a>. And two anonymous alchemists, 3382 working independently in different copies, decoded the love story as an 3383 <a href="dictionary/alchemical-allegory.html">alchemical allegory</a> — 3384 but they disagreed about which kind.</p> 3385 3386 <p>The BL alchemist (Hand B) followed the framework of Jean d'Espagnet, 3387 reading the HP as encoding the operations of 3388 <a href="dictionary/master-mercury.html">Master Mercury</a>. The Buffalo 3389 alchemist (Hand E) followed pseudo-Geber, reading it through the lens of 3390 <a href="dictionary/sol-luna.html">Sol and Luna</a> and the 3391 <a href="dictionary/chemical-wedding.html">chemical wedding</a>. Their 3392 competing readings are explored in the 3393 <a href="russell-alchemical-hands.html">Alchemical Hands essay</a>.</p> 3394 3395 <p>Russell's concept of the HP as a 3396 <a href="dictionary/activity-book.html">"humanistic activity book"</a> — 3397 a text whose puzzles, obscure language, and visual-textual interplay invited 3398 readers to cultivate <a href="dictionary/ingegno.html"><em>ingegno</em></a> 3399 (improvisational intelligence) through creative annotation — is the 3400 central argument of his thesis and the intellectual foundation of this site.</p> 3401 3402 <div class="cross-links"> 3403 <h4>Explore Further</h4> 3404 <a href="marginalia/index.html">Browse the Marginalia</a> 3405 <a href="woodcuts/index.html">See the Woodcuts</a> 3406 <a href="dictionary/index.html">Dictionary of Terms</a> 3407 <a href="russell-alchemical-hands.html">The Alchemical Hands</a> 3408 <a href="timeline.html">500 Years of Reception</a> 3409 <a href="scholars.html">The Scholars</a> 3410 <a href="manuscripts/index.html">The Copies</a> 3411 </div> 3412 </div>""" 3413 3414 page = page_shell('The Book', body, active_nav='thebook', extra_css=book_css) 3415 (SITE_DIR / 'the-book.html').write_text(page, encoding='utf-8') 3416 print(" the-book.html") 3417 3418 3419# ============================================================ 3420# Digital Edition stub page 3421# ============================================================ 3422 3423def build_digital_edition_page(conn): 3424 """Generate digital-edition.html — editions of the Hypnerotomachia Poliphili.""" 3425 cur = conn.cursor() 3426 3427 # Load editions data 3428 cur.execute(""" 3429 SELECT id, title, year, city, printer_publisher, translator, 3430 language, edition_type, description, significance, 3431 woodcut_info, digital_facsimile_url, worldcat_url, 3432 extant_copies, slug 3433 FROM editions ORDER BY year 3434 """) 3435 editions = cur.fetchall() 3436 3437 type_labels = { 3438 'FIRST_EDITION': 'First Edition', 3439 'REPRINT': 'Reprint', 3440 'TRANSLATION': 'Translation', 3441 'ADAPTATION': 'Adaptation', 3442 'FACSIMILE': 'Facsimile', 3443 'CRITICAL_EDITION': 'Critical Edition', 3444 'MODERN_TRANSLATION': 'Modern Translation', 3445 } 3446 type_colors = { 3447 'FIRST_EDITION': '#8b5cf6', 3448 'REPRINT': '#6366f1', 3449 'TRANSLATION': '#3b82f6', 3450 'ADAPTATION': '#ef4444', 3451 'CRITICAL_EDITION': '#10b981', 3452 'MODERN_TRANSLATION': '#f59e0b', 3453 } 3454 3455 edition_css = '<style>' + """ 3456 .editions-page { max-width: 1000px; margin: 2rem auto; padding: 0 2rem; } 3457 .editions-page h1 { color: var(--accent); font-size: 1.8rem; } 3458 .editions-page .intro { max-width: 800px; line-height: 1.8; margin-bottom: 2rem; color: var(--text-muted); } 3459 .edition-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px; 3460 padding: 1.5rem; margin-bottom: 1.5rem; transition: box-shadow 0.2s; } 3461 .edition-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); } 3462 .edition-card h3 { margin: 0 0 0.5rem; color: var(--text); font-size: 1.1rem; } 3463 .edition-card .ed-meta { font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.75rem; 3464 font-family: var(--font-sans); } 3465 .edition-card .ed-type { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 2px; 3466 font-size: 0.65rem; font-weight: 600; text-transform: uppercase; 3467 color: white; margin-right: 0.5rem; } 3468 .edition-card p { line-height: 1.7; margin: 0.5rem 0; font-size: 0.92rem; } 3469 .edition-card .ed-significance { border-top: 1px solid var(--border); padding-top: 0.75rem; 3470 margin-top: 0.75rem; } 3471 .edition-card .ed-significance strong { color: var(--accent); } 3472 .edition-card .ed-woodcuts { font-size: 0.85rem; color: var(--text-muted); font-style: italic; 3473 margin-top: 0.5rem; } 3474 .edition-card .ed-links { margin-top: 0.75rem; } 3475 .edition-card .ed-links a { display: inline-block; margin-right: 0.75rem; padding: 0.3rem 0.7rem; 3476 background: var(--bg); border: 1px solid var(--border); border-radius: 3px; 3477 font-size: 0.8rem; color: var(--accent); text-decoration: none; } 3478 .edition-card .ed-links a:hover { border-color: var(--accent); background: var(--accent); color: white; } 3479 .editions-timeline { position: relative; padding-left: 2rem; } 3480 .editions-timeline::before { content: ''; position: absolute; left: 0.5rem; top: 0; bottom: 0; 3481 width: 2px; background: var(--border); } 3482 .edition-card::before { content: ''; position: absolute; left: -1.55rem; top: 1.5rem; 3483 width: 10px; height: 10px; border-radius: 50%; 3484 background: var(--accent); border: 2px solid var(--bg); } 3485 .edition-card { position: relative; } 3486 """ + '</style>' 3487 3488 # Build edition cards 3489 cards = '' 3490 for ed in editions: 3491 (eid, title, year, city, printer, translator, lang, etype, 3492 desc, significance, woodcuts_info, facsimile_url, worldcat_url, 3493 extant, slug) = ed 3494 3495 color = type_colors.get(etype, '#6b7280') 3496 type_label = type_labels.get(etype, etype or '') 3497 type_badge = f'<span class="ed-type" style="background:{color}">{type_label}</span>' 3498 3499 meta_parts = [str(year)] 3500 if city: 3501 meta_parts.append(escape(city)) 3502 if printer: 3503 meta_parts.append(escape(printer)) 3504 3505 translator_line = f'<br>Translator: {escape(translator)}' if translator else '' 3506 lang_line = f' · {escape(lang)}' if lang else '' 3507 3508 desc_html = f'<p>{escape(desc)}</p>' if desc else '' 3509 sig_html = f'<div class="ed-significance"><strong>Significance:</strong> {escape(significance)}</div>' if significance else '' 3510 wc_html = f'<p class="ed-woodcuts">{escape(woodcuts_info)}</p>' if woodcuts_info else '' 3511 extant_html = f' · {extant} extant copies' if extant else '' 3512 3513 links = '' 3514 link_parts = [] 3515 if facsimile_url: 3516 link_parts.append(f'<a href="{escape(facsimile_url)}" target="_blank">Digital Facsimile →</a>') 3517 if worldcat_url: 3518 link_parts.append(f'<a href="{escape(worldcat_url)}" target="_blank">WorldCat →</a>') 3519 if link_parts: 3520 links = f'<div class="ed-links">{"".join(link_parts)}</div>' 3521 3522 cards += f""" 3523 <div class="edition-card"> 3524 <h3>{escape(title)}</h3> 3525 <div class="ed-meta">{type_badge} {" · ".join(meta_parts)}{lang_line}{extant_html}{translator_line}</div> 3526 {desc_html} 3527 {sig_html} 3528 {wc_html} 3529 {links} 3530 </div>""" 3531 3532 body = f""" 3533 <div class="editions-page"> 3534 <p><a href="index.html">← Home</a></p> 3535 <h1>Editions of the <em>Hypnerotomachia Poliphili</em></h1> 3536 <p class="intro">From its first printing by Aldus Manutius in 1499 to Joscelyn Godwin's 3537 complete English translation exactly five centuries later, the <em>Hypnerotomachia Poliphili</em> 3538 has been published, translated, adapted, and reinterpreted across languages, centuries, and 3539 intellectual traditions. Each edition reflects the concerns of its moment: humanist philology 3540 in 1499, Mannerist aesthetics in 1546, alchemical hermeneutics in 1600, and scholarly 3541 archaeology from 1980 onward.</p> 3542 3543 <div class="editions-timeline"> 3544 {cards} 3545 </div> 3546 </div>""" 3547 3548 page = page_shell('Editions', body, active_nav='edition', extra_css=edition_css) 3549 (SITE_DIR / 'digital-edition.html').write_text(page, encoding='utf-8') 3550 print(f" digital-edition.html ({len(editions)} editions)") 3551 3552 3553# ============================================================ 3554# Update main index.html with nav 3555# ============================================================ 3556 3557def update_index_nav(): 3558 """Replace nav bar in index.html with current version, fix CSS links.""" 3559 index_path = SITE_DIR / 'index.html' 3560 if not index_path.exists(): 3561 return 3562 3563 content = index_path.read_text(encoding='utf-8') 3564 updated = False 3565 3566 # Replace any existing nav with current 8-link version 3567 import re 3568 nav = nav_html('home') 3569 old_nav_pattern = r'<nav class="site-nav">.*?</nav>' 3570 if re.search(old_nav_pattern, content): 3571 content = re.sub(old_nav_pattern, nav, content) 3572 updated = True 3573 elif 'site-nav' not in content: 3574 content = content.replace( 3575 '</div>\n </header>', 3576 f' {nav}\n </div>\n </header>', 3577 1 3578 ) 3579 updated = True 3580 3581 # Fix CSS links: add scholars.css and components.css if missing 3582 if 'scholars.css' not in content: 3583 content = content.replace( 3584 '<link rel="stylesheet" href="style.css">', 3585 '<link rel="stylesheet" href="style.css">\n <link rel="stylesheet" href="scholars.css">\n <link rel="stylesheet" href="components.css">' 3586 ) 3587 updated = True 3588 3589 # Add meta description if missing 3590 if 'meta name="description"' not in content: 3591 content = content.replace( 3592 '<meta name="viewport"', 3593 '<meta name="description" content="Digital scholarship and marginalia of the Hypnerotomachia Poliphili (Venice, 1499)">\n <meta name="viewport"' 3594 ) 3595 updated = True 3596 3597 if updated: 3598 index_path.write_text(content, encoding='utf-8') 3599 print(" index.html: nav + CSS updated") 3600 3601 3602# ============================================================ 3603# Update CSS for new components 3604# ============================================================ 3605 3606def update_styles(): 3607 """Append new styles to style.css for nav, badges, etc.""" 3608 css_path = SITE_DIR / 'style.css' 3609 content = css_path.read_text(encoding='utf-8') 3610 3611 if 'site-nav' in content: 3612 return # Already updated 3613 3614 additions = """ 3615 3616/* ===== Site Navigation ===== */ 3617.site-nav { 3618 margin-top: 1.5rem; 3619 display: flex; 3620 justify-content: center; 3621 gap: 0.25rem; 3622 flex-wrap: wrap; 3623} 3624 3625.site-nav a { 3626 color: #9a8c7a; 3627 text-decoration: none; 3628 font-family: var(--font-sans); 3629 font-size: 0.9rem; 3630 padding: 0.4rem 1rem; 3631 border-radius: 3px; 3632 transition: all 0.2s; 3633} 3634 3635.site-nav a:hover { 3636 color: var(--accent-light); 3637 background: rgba(255,255,255,0.05); 3638} 3639 3640.site-nav a.active { 3641 color: var(--header-text); 3642 background: rgba(255,255,255,0.1); 3643} 3644 3645/* ===== Review & Confidence Badges ===== */ 3646.review-badge { 3647 display: inline-block; 3648 padding: 0.1rem 0.4rem; 3649 background: #fff3cd; 3650 color: #856404; 3651 border-radius: 2px; 3652 font-size: 0.65rem; 3653 font-weight: 600; 3654 font-family: var(--font-sans); 3655 text-transform: uppercase; 3656 letter-spacing: 0.03em; 3657 vertical-align: middle; 3658 margin-left: 0.3rem; 3659} 3660 3661.confidence-badge { 3662 display: inline-block; 3663 padding: 0.1rem 0.4rem; 3664 border-radius: 2px; 3665 font-size: 0.65rem; 3666 font-weight: 600; 3667 font-family: var(--font-sans); 3668 text-transform: uppercase; 3669 letter-spacing: 0.03em; 3670 vertical-align: middle; 3671 margin-left: 0.3rem; 3672} 3673 3674.confidence-high { background: #d4edda; color: #155724; } 3675.confidence-medium { background: #fff3cd; color: #856404; } 3676.confidence-low { background: #f8d7da; color: #721c24; } 3677.confidence-provisional { background: #e2e3e5; color: #383d41; } 3678 3679/* ===== Review Status Badges ===== */ 3680.review-status-badge { 3681 display: inline-block; 3682 padding: 0.15rem 0.5rem; 3683 border-radius: 3px; 3684 font-size: 0.65rem; 3685 font-weight: 700; 3686 font-family: var(--font-sans); 3687 text-transform: uppercase; 3688 letter-spacing: 0.04em; 3689 vertical-align: middle; 3690 margin-left: 0.3rem; 3691} 3692.review-badge-draft { background: #fff3cd; color: #856404; } 3693.review-badge-reviewed { background: #cce5ff; color: #004085; } 3694.review-badge-verified { background: #d4edda; color: #155724; } 3695.review-badge-provisional { background: #e2e3e5; color: #383d41; } 3696 3697/* ===== Dictionary enrichment sections ===== */ 3698.dict-section { margin-top: 1.5rem; } 3699.dict-section h3 { font-size: 1rem; color: var(--accent); margin-bottom: 0.5rem; border-bottom: 1px solid var(--border); padding-bottom: 0.2rem; } 3700.dict-section p { line-height: 1.7; } 3701.evidence-list { font-size: 0.9rem; color: var(--text-muted); line-height: 1.6; } 3702.evidence-list li { margin-bottom: 0.5rem; } 3703.source-docs { font-size: 0.85rem; color: var(--text-muted); } 3704.source-pages { font-size: 0.85rem; color: var(--text-muted); margin-top: 0.5rem; } 3705.provenance-section { background: var(--bg-card); padding: 1rem; border-radius: 4px; margin-top: 2rem; } 3706.provenance-status { margin-bottom: 0.5rem; } 3707.provenance-list { font-size: 0.85rem; color: var(--text-muted); } 3708.provenance-list li { margin-bottom: 0.3rem; } 3709""" 3710 3711 css_path.write_text(content + additions, encoding='utf-8') 3712 print(" style.css: nav + badge styles added") 3713 3714 3715# ============================================================ 3716# Concordance Browser 3717# ============================================================ 3718 3719def build_concordance_browser(conn): 3720 """Generate concordance/index.html — 448-page browser of the 1499 HP.""" 3721 cur = conn.cursor() 3722 conc_dir = SITE_DIR / 'concordance' 3723 conc_dir.mkdir(exist_ok=True) 3724 ia_img_dir = SITE_DIR / 'images' / 'woodcuts_1499' 3725 3726 # Which signatures have marginalia pages? 3727 marg_dir = SITE_DIR / 'marginalia' 3728 marg_sigs = set() 3729 if marg_dir.exists(): 3730 for f in marg_dir.glob('*.html'): 3731 if f.stem != 'index': 3732 marg_sigs.add(f.stem) 3733 3734 cur.execute(""" 3735 SELECT pc.page_seq, pc.signature, pc.folio_number, pc.side, pc.quire, 3736 pc.section, pc.has_woodcut, pc.bl_photo_number, 3737 pc.ia_page_index, pc.notes, 3738 w.slug as woodcut_slug, w.title as woodcut_title, 3739 w.subject_category as woodcut_category 3740 FROM page_concordance pc 3741 LEFT JOIN woodcuts w ON pc.page_seq = w.page_1499 3742 ORDER BY pc.page_seq 3743 """) 3744 pages = cur.fetchall() 3745 3746 if not pages: 3747 print(" concordance: no data") 3748 return 3749 3750 section_colors = { 3751 'PRELIMINARIES': '#6b7280', 'DARK_FOREST': '#065f46', 'PYRAMID_RUINS': '#92400e', 3752 'DRAGON_PORTAL': '#7c2d12', 'FIVE_SENSES': '#4338ca', 'QUEEN_PALACE': '#7e22ce', 3753 'JOURNEY_DOORS': '#0369a1', 'PROCESSION': '#b91c1c', 'VENUS_TEMPLE': '#be185d', 3754 'POLYANDRION': '#374151', 'CYTHERA_VOYAGE': '#0891b2', 'CYTHERA_GARDENS': '#15803d', 3755 'VENUS_FOUNTAIN': '#9333ea', 'BOOK_II_POLIA': '#78350f', 'COLOPHON': '#6b7280', 3756 } 3757 section_labels = { 3758 'PRELIMINARIES': 'Preliminaries', 'DARK_FOREST': 'Dark Forest', 3759 'PYRAMID_RUINS': 'Pyramid & Ruins', 'DRAGON_PORTAL': 'Dragon Portal', 3760 'FIVE_SENSES': 'Five Senses', 'QUEEN_PALACE': "Queen's Palace", 3761 'JOURNEY_DOORS': 'Journey of the Doors', 'PROCESSION': 'Triumphal Procession', 3762 'VENUS_TEMPLE': 'Temple of Venus', 'POLYANDRION': 'Polyandrion', 3763 'CYTHERA_VOYAGE': 'Voyage to Cythera', 'CYTHERA_GARDENS': 'Gardens of Cythera', 3764 'VENUS_FOUNTAIN': 'Fountain of Venus', 'BOOK_II_POLIA': 'Book II: Polia', 3765 'COLOPHON': 'Colophon', 3766 } 3767 3768 conc_css = '<style>' + """ 3769 .conc-page { max-width: 1100px; margin: 2rem auto; padding: 0 2rem; } 3770 .conc-page h1 { color: var(--accent); font-size: 1.8rem; margin-bottom: 0.3rem; } 3771 .conc-page .intro { max-width: 800px; line-height: 1.8; margin-bottom: 1.5rem; color: var(--text-muted); } 3772 .conc-controls { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 1.5rem; 3773 padding: 0.75rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px; } 3774 .conc-controls label { font-size: 0.8rem; color: var(--text-muted); cursor: pointer; display: flex; align-items: center; gap: 0.3rem; } 3775 .conc-controls input[type="checkbox"] { accent-color: var(--accent); } 3776 .conc-jump { padding: 0.35rem 0.6rem; border: 1px solid var(--border); border-radius: 3px; font-size: 0.85rem; 3777 font-family: var(--font-sans); width: 120px; } 3778 .conc-jump:focus { outline: none; border-color: var(--accent); } 3779 .conc-stats { font-size: 0.8rem; color: var(--text-muted); margin-left: auto; } 3780 .conc-section { margin-bottom: 1rem; } 3781 .conc-section-header { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; 3782 background: var(--bg); border: 1px solid var(--border); border-radius: 4px; 3783 cursor: pointer; position: sticky; top: 0; z-index: 5; user-select: none; } 3784 .conc-section-header:hover { background: var(--bg-card); } 3785 .conc-section-header .sec-badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 2px; 3786 font-size: 0.7rem; font-weight: 600; color: white; text-transform: uppercase; } 3787 .conc-section-header .sec-label { font-weight: 600; font-size: 0.95rem; } 3788 .conc-section-header .sec-count { font-size: 0.75rem; color: var(--text-muted); margin-left: auto; } 3789 .conc-section-header .sec-toggle { font-size: 0.8rem; color: var(--text-muted); transition: transform 0.2s; } 3790 .conc-section.collapsed .sec-toggle { transform: rotate(-90deg); } 3791 .conc-section.collapsed .conc-rows { display: none; } 3792 .conc-rows { border: 1px solid var(--border); border-top: none; border-radius: 0 0 4px 4px; } 3793 .conc-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.75rem; 3794 border-bottom: 1px solid #eee; font-size: 0.82rem; transition: background 0.1s; } 3795 .conc-row:last-child { border-bottom: none; } 3796 .conc-row:hover { background: var(--bg); } 3797 .conc-row.has-wc { background: rgba(139,69,19,0.04); } 3798 .conc-row .pg { width: 45px; font-weight: 600; color: var(--text); font-family: var(--font-sans); } 3799 .conc-row .sig { width: 45px; font-family: var(--font-sans); color: var(--accent); } 3800 .conc-row .folio { width: 55px; color: var(--text-muted); font-family: var(--font-sans); } 3801 .conc-row .quire-col { width: 30px; color: var(--text-muted); font-family: var(--font-sans); text-align: center; } 3802 .conc-row .wc-thumb { width: 60px; height: 45px; flex-shrink: 0; } 3803 .conc-row .wc-thumb img { width: 60px; height: 45px; object-fit: cover; object-position: top; 3804 border: 1px solid var(--border); border-radius: 2px; } 3805 .conc-row .wc-info { flex: 1; min-width: 0; } 3806 .conc-row .wc-title { font-size: 0.8rem; color: var(--text); } 3807 .conc-row .wc-title a { color: var(--text); text-decoration: none; } 3808 .conc-row .wc-title a:hover { color: var(--accent); } 3809 .conc-row .wc-cat { font-size: 0.65rem; font-weight: 600; text-transform: uppercase; padding: 0.1rem 0.3rem; 3810 border-radius: 2px; color: white; } 3811 .conc-row .links { display: flex; gap: 0.3rem; flex-shrink: 0; } 3812 .conc-row .links a { font-size: 0.7rem; padding: 0.15rem 0.4rem; border: 1px solid var(--border); 3813 border-radius: 2px; text-decoration: none; color: var(--text-muted); background: var(--bg-card); } 3814 .conc-row .links a:hover { border-color: var(--accent); color: var(--accent); } 3815 .conc-row.hidden { display: none; } 3816 .conc-row.highlight { background: #fef3c7 !important; } 3817 @media (max-width: 768px) { 3818 .conc-row .folio, .conc-row .quire-col { display: none; } 3819 .conc-row .wc-thumb { width: 40px; height: 30px; } 3820 .conc-row .wc-thumb img { width: 40px; height: 30px; } 3821 } 3822 """ + '</style>' 3823 3824 wc_cat_colors = { 3825 'ARCHITECTURAL': '#8b5cf6', 'LANDSCAPE': '#10b981', 'NARRATIVE': '#3b82f6', 3826 'HIEROGLYPHIC': '#f59e0b', 'PROCESSION': '#ef4444', 'DECORATIVE': '#6366f1', 3827 'PORTRAIT': '#ec4899', 'DIAGRAM': '#14b8a6', 3828 } 3829 3830 # Group pages by section 3831 from collections import OrderedDict 3832 sections = OrderedDict() 3833 total_wc = 0 3834 for row in pages: 3835 (pg, sig, folio, side, quire, section, has_wc, bl_photo, 3836 ia_page, notes, wc_slug, wc_title, wc_cat) = row 3837 if section not in sections: 3838 sections[section] = [] 3839 sections[section].append(row) 3840 if has_wc: 3841 total_wc += 1 3842 3843 # Build section blocks 3844 sections_html = '' 3845 for section, sec_pages in sections.items(): 3846 color = section_colors.get(section, '#6b7280') 3847 label = section_labels.get(section, section.replace('_', ' ').title()) 3848 wc_in_sec = sum(1 for p in sec_pages if p[6]) 3849 pg_range = f'pp.{sec_pages[0][0]}–{sec_pages[-1][0]}' 3850 3851 rows_html = '' 3852 for (pg, sig, folio, side, quire, sec, has_wc, bl_photo, 3853 ia_page, notes, wc_slug, wc_title, wc_cat) in sec_pages: 3854 3855 row_cls = 'conc-row' 3856 data_attrs = f'data-section="{section}" data-page="{pg}" data-sig="{sig}"' 3857 if has_wc: 3858 row_cls += ' has-wc' 3859 data_attrs += ' data-woodcut="1"' 3860 3861 # Thumbnail 3862 thumb_html = '<div class="wc-thumb"></div>' 3863 if has_wc: 3864 img_file = f'hp1499_p{pg:03d}.jpg' 3865 if (ia_img_dir / img_file).exists(): 3866 thumb_html = f'<div class="wc-thumb"><img src="../images/woodcuts_1499/{img_file}" alt="" loading="lazy"></div>' 3867 3868 # Woodcut info 3869 wc_html = '<div class="wc-info"></div>' 3870 if wc_title and wc_slug: 3871 # Check if this woodcut's detail page exists (CORPUS_EXTRACTION pages were moved to Alchemical Hands) 3872 wc_page_exists = (SITE_DIR / 'woodcuts' / f'{wc_slug}.html').exists() 3873 if wc_page_exists: 3874 cat_badge = f'<span class="wc-cat" style="background:{wc_cat_colors.get(wc_cat, "#6b7280")}">{escape(wc_cat or "")}</span> ' if wc_cat else '' 3875 wc_html = f'<div class="wc-info"><span class="wc-title"><a href="../woodcuts/{wc_slug}.html">{cat_badge}{escape(wc_title[:50])}</a></span></div>' 3876 else: 3877 # Moved to Alchemical Hands page 3878 wc_html = f'<div class="wc-info"><span class="wc-title"><a href="../russell-alchemical-hands.html#annotated-woodcuts" style="color:var(--accent)">{escape(wc_title[:50])}</a></span></div>' 3879 elif has_wc and not wc_slug: 3880 wc_html = '<div class="wc-info"><span class="wc-title" style="color:var(--accent)">Annotated woodcut</span></div>' 3881 3882 # Links 3883 links = [] 3884 sig_lower = sig.lower() if sig else '' 3885 if sig_lower in marg_sigs: 3886 links.append(f'<a href="../marginalia/{sig_lower}.html" title="View marginalia">M</a>') 3887 if bl_photo: 3888 bl_file = f'C_60_o_12-{bl_photo:03d}.jpg' 3889 if (SITE_DIR / 'images' / 'bl' / bl_file).exists(): 3890 links.append(f'<a href="../images/bl/{bl_file}" title="BL photo #{bl_photo}" target="_blank">BL</a>') 3891 links_html = f'<div class="links">{"".join(links)}</div>' if links else '' 3892 3893 rows_html += f""" 3894 <div class="{row_cls}" {data_attrs} id="p{pg}"> 3895 <span class="pg">p.{pg}</span> 3896 <span class="sig">{escape(sig or '')}</span> 3897 <span class="folio">f.{folio}{side}</span> 3898 <span class="quire-col">{escape(quire or '')}</span> 3899 {thumb_html} 3900 {wc_html} 3901 {links_html} 3902 </div>""" 3903 3904 sections_html += f""" 3905 <div class="conc-section" data-section="{section}"> 3906 <div class="conc-section-header" onclick="toggleSection(this)"> 3907 <span class="sec-badge" style="background:{color}">{escape(label)}</span> 3908 <span class="sec-label">{pg_range}</span> 3909 <span class="sec-count">{len(sec_pages)} pages, {wc_in_sec} woodcuts</span> 3910 <span class="sec-toggle">▼</span> 3911 </div> 3912 <div class="conc-rows">{rows_html}</div> 3913 </div>""" 3914 3915 # Filter buttons for sections 3916 sec_buttons = '' 3917 for section in sections: 3918 color = section_colors.get(section, '#6b7280') 3919 label = section_labels.get(section, section) 3920 sec_buttons += f'<button class="wc-filter-btn" onclick="filterSection(\'{section}\')" style="border-color:{color}">{label}</button> ' 3921 3922 conc_js = """ 3923 <script> 3924 function toggleSection(header) { 3925 header.parentElement.classList.toggle('collapsed'); 3926 } 3927 function filterSection(sec) { 3928 document.querySelectorAll('.conc-section').forEach(s => { 3929 if (sec === 'ALL') { s.style.display = ''; } 3930 else { s.style.display = s.dataset.section === sec ? '' : 'none'; } 3931 }); 3932 document.querySelectorAll('.wc-filter-btn').forEach(b => b.classList.remove('active')); 3933 event.target.classList.add('active'); 3934 updateStats(); 3935 } 3936 function filterWoodcuts(checked) { 3937 document.querySelectorAll('.conc-row').forEach(r => { 3938 if (checked && !r.dataset.woodcut) { r.classList.add('hidden'); } 3939 else { r.classList.remove('hidden'); } 3940 }); 3941 updateStats(); 3942 } 3943 function jumpToPage(val) { 3944 val = val.trim().toLowerCase(); 3945 if (!val) return; 3946 // Try as page number 3947 let target = document.getElementById('p' + val); 3948 // Try as signature 3949 if (!target) { 3950 const rows = document.querySelectorAll('.conc-row'); 3951 for (const r of rows) { 3952 if (r.dataset.sig && r.dataset.sig.toLowerCase() === val) { 3953 target = r; break; 3954 } 3955 } 3956 } 3957 if (target) { 3958 // Expand section if collapsed 3959 const sec = target.closest('.conc-section'); 3960 if (sec && sec.classList.contains('collapsed')) { 3961 sec.classList.remove('collapsed'); 3962 } 3963 // Show if hidden 3964 target.classList.remove('hidden'); 3965 // Scroll and highlight 3966 target.scrollIntoView({ behavior: 'smooth', block: 'center' }); 3967 document.querySelectorAll('.conc-row.highlight').forEach(r => r.classList.remove('highlight')); 3968 target.classList.add('highlight'); 3969 setTimeout(() => target.classList.remove('highlight'), 3000); 3970 } 3971 } 3972 function updateStats() { 3973 const visible = document.querySelectorAll('.conc-row:not(.hidden):not([style*="display: none"])'); 3974 const total = document.querySelectorAll('.conc-row'); 3975 const wc = document.querySelectorAll('.conc-row.has-wc:not(.hidden)'); 3976 const el = document.getElementById('conc-stats'); 3977 if (el) el.textContent = visible.length + ' of ' + total.length + ' pages shown'; 3978 } 3979 document.getElementById('conc-jump')?.addEventListener('keydown', function(e) { 3980 if (e.key === 'Enter') { jumpToPage(this.value); } 3981 }); 3982 document.getElementById('wc-only')?.addEventListener('change', function() { 3983 filterWoodcuts(this.checked); 3984 }); 3985 </script>""" 3986 3987 body = f""" 3988 <div class="conc-page"> 3989 <p><a href="../concordance-method.html">Methodology & Confidence →</a></p> 3990 <h1>Page Concordance</h1> 3991 <p class="intro">All 448 page surfaces of the 1499 Aldine first edition, mapped across three 3992 numbering systems: sequential page, bibliographic signature (quire + leaf + recto/verso), and 3993 folio number. Pages with woodcut illustrations show thumbnails from the Internet Archive facsimile 3994 (University of Seville copy). Links connect to marginalia folios (M) and British Library photographs (BL) 3995 where available.</p> 3996 3997 <div class="conc-controls"> 3998 <input type="text" class="conc-jump" id="conc-jump" placeholder="Jump to p.123 or b6v"> 3999 <label><input type="checkbox" id="wc-only"> Woodcut pages only</label> 4000 <span class="conc-stats" id="conc-stats">{len(pages)} pages, {total_wc} with woodcuts</span> 4001 </div> 4002 4003 {sections_html} 4004 </div>""" 4005 4006 page_html = page_shell("Page Concordance", body, active_nav='concordance', 4007 extra_css=conc_css, extra_js=conc_js, depth=1) 4008 (conc_dir / 'index.html').write_text(page_html, encoding='utf-8') 4009 print(f" concordance/index.html ({len(pages)} pages, {total_wc} woodcuts)") 4010 4011 4012# ============================================================ 4013# Main 4014# ============================================================ 4015 4016def main(): 4017 print("=== Building Site ===\n") 4018 4019 conn = sqlite3.connect(DB_PATH) 4020 4021 print("Exporting data...") 4022 export_data_json(conn) 4023 4024 print("\nBuilding pages...") 4025 update_styles() 4026 update_index_nav() 4027 build_scholars_pages(conn) 4028 build_dictionary_pages(conn) 4029 build_marginalia_pages(conn) 4030 build_bibliography_page(conn) 4031 build_docs_pages() 4032 build_code_pages() 4033 build_about_page(conn) 4034 build_the_book_page() 4035 build_russell_essay_page(conn) 4036 build_concordance_essay_page(conn) 4037 build_digital_edition_page(conn) 4038 build_timeline_page(conn) 4039 build_woodcuts_pages(conn) 4040 build_concordance_browser(conn) 4041 build_manuscripts_pages(conn) 4042 4043 conn.close() 4044 print("\n=== Build Complete ===") 4045 4046 4047if __name__ == "__main__": 4048 main()