#!/usr/bin/env python3 import json, re, shutil, subprocess, tempfile from pathlib import Path ROOT=Path(__file__).resolve().parent MD,FIG,SITE=ROOT/'markdown',ROOT/'figures',ROOT/'site' def main(): FIG.mkdir(exist_ok=True); sources=sorted(MD.glob('NaturalHazards.Chapter[0-9]*.md'),key=lambda p:int(re.search(r'Chapter(\d+)',p.name).group(1))); mapping={} for source in sources: ch=int(re.search(r'Chapter(\d+)',source.name).group(1)); pdf=ROOT/f'NaturalHazards.Chapter{ch}.pdf' if not pdf.exists(): continue text=source.read_text(encoding='utf-8'); text=re.sub(r'\n*!\[Source PDF page \d+, containing Figure [^\]]+\]\([^\n]+\)\n*', '\n', text); pages=subprocess.run(['pdftotext','-layout',str(pdf),'-'],check=True,capture_output=True,text=True).stdout.split('\f') raw_ids=re.findall(rf'Figure\s+{ch}\.\d+(?:\.[A-Z])?[A-Z]?(?![\d.])',text,re.I); ids=sorted({x.casefold():re.sub(r'^figure','Figure',x,flags=re.I) for x in raw_ids}.values(),key=lambda s:(int(re.search(r'\.(\d+)',s).group(1)),s)); found={} for fig in ids: cap=re.escape(fig)+r"(?![\d.])(?:\s*:\s*|\s+)(?=(?-i:[A-Z]))" md_caps=list(re.finditer(cap,text,re.I)); candidates=[] for i,page in enumerate(pages): for pm in re.finditer(cap,page,re.I): pdf_words=set(re.findall(r"[a-zA-Z]{4,}",page[pm.end():pm.end()+220].lower())) for mm in md_caps: md_words=set(re.findall(r"[a-zA-Z]{4,}",text[mm.end():mm.end()+220].lower())) score=len(pdf_words & md_words) candidates.append((score,i+1,mm.start())) if candidates: score,page,pos=max(candidates); found[fig]=page; found[fig+'|offset']=pos inserts=[] for key,page in list(found.items()): if '|' in key: continue pos=found.get(key+'|offset') if pos is not None: inserts.append((pos,f'\n\n![Source PDF page {page}, containing {key}](../figures/chapter-{ch}-page-{page:02}.jpg)\n\n')) found={k:v for k,v in found.items() if '|' not in k}; mapping[source.name]=found for pos,value in sorted(inserts,reverse=True): text=text[:pos]+value+text[pos:] source.write_text(text,encoding='utf-8') if found: with tempfile.TemporaryDirectory(prefix='natural-hazards-') as temp: for page in sorted(set(found.values())): target=FIG/f'chapter-{ch}-page-{page:02}.jpg' if target.exists(): continue prefix=str(Path(temp)/'page') subprocess.run(['pdftoppm','-f',str(page),'-l',str(page),'-jpeg','-scale-to','1400','-jpegopt','quality=78',str(pdf),prefix],check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) shutil.copy2(f'{prefix}-{page:02}.jpg',target) # Appendices use lettered figure numbers and a separate source PDF. source=MD/'NaturalHazards.Appendices.md'; pdf=ROOT/'NaturalHazards.Appendices.pdf' text=source.read_text(encoding='utf-8'); text=re.sub(r'\n*!\[Source PDF page \d+, containing Figure [^\]]+\]\([^\n]+\)\n*','\n',text) pages=subprocess.run(['pdftotext','-layout',str(pdf),'-'],check=True,capture_output=True,text=True).stdout.split('\f') raw_ids=re.findall(r'Figure\s+[A-Z]\.\d+[A-Z]?(?![\d.])',text,re.I); ids=sorted({x.casefold():re.sub(r'^figure','Figure',x,flags=re.I) for x in raw_ids}.values()); found={} for fig in ids: cap=re.escape(fig)+r"(?![\d.])(?:\s*:\s*|\s+)(?=(?-i:[A-Z]))"; md_caps=list(re.finditer(cap,text,re.I)); candidates=[] for i,page in enumerate(pages): for pm in re.finditer(cap,page,re.I): pdf_words=set(re.findall(r'[a-zA-Z]{4,}',page[pm.end():pm.end()+220].lower())) for mm in md_caps: md_words=set(re.findall(r'[a-zA-Z]{4,}',text[mm.end():mm.end()+220].lower())); candidates.append((len(pdf_words & md_words),i+1,mm.start())) if candidates: score,page,pos=max(candidates); found[fig]=page; found[fig+'|offset']=pos inserts=[] for key,page in list(found.items()): if '|' not in key: inserts.append((found[key+'|offset'],f'\n\n![Source PDF page {page}, containing {key}](../figures/appendices-page-{page:02}.jpg)\n\n')) found={k:v for k,v in found.items() if '|' not in k}; mapping[source.name]=found for pos,value in sorted(inserts,reverse=True): text=text[:pos]+value+text[pos:] source.write_text(text,encoding='utf-8') with tempfile.TemporaryDirectory(prefix='natural-hazards-appendices-') as temp: for page in sorted(set(found.values())): target=FIG/f'appendices-page-{page:02}.jpg' if target.exists(): continue prefix=str(Path(temp)/'page'); subprocess.run(['pdftoppm','-f',str(page),'-l',str(page),'-jpeg','-scale-to','1400','-jpegopt','quality=78',str(pdf),prefix],check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL); shutil.copy2(f'{prefix}-{page:02}.jpg',target) # The complete-book Markdown also gets links to the same chapter and appendix images. full=MD/"Natural hazards_ earth's processes as hazards, disasters, and catastrophes.md" if full.exists(): full_text=full.read_text(encoding='utf-8'); full_text=re.sub(r'\n*!\[Source PDF page \d+, containing Figure [^\]]+\]\([^\n]+\)\n*','\n',full_text); assets={} for name,items in mapping.items(): if name==full.name: continue for fig,page in items.items(): if name=='NaturalHazards.Appendices.md': rel=f'../figures/appendices-page-{page:02}.jpg' else: chapter=re.search(r'Chapter(\d+)',name).group(1); rel=f'../figures/chapter-{chapter}-page-{page:02}.jpg' assets[fig]=(page,rel) inserts=[] for fig,(page,rel) in assets.items(): cap=re.escape(fig)+r"(?![\d.])(?:\s*:\s*|\s+)(?=(?-i:[A-Z]))"; match=re.search(cap,full_text,re.I) if match: inserts.append((match.start(),f'\n\n![Source PDF page {page}, containing {fig}]({rel})\n\n')) for pos,value in sorted(inserts,reverse=True): full_text=full_text[:pos]+value+full_text[pos:] full.write_text(full_text,encoding='utf-8'); mapping[full.name]={fig:rel for fig,(_,rel) in assets.items() if re.search(re.escape(fig)+r"(?![\d.])(?:\s*:\s*|\s+)(?=(?-i:[A-Z]))",full_text,re.I)} (ROOT/'figure-pages.json').write_text(json.dumps(mapping,indent=2)+'\n'); make_site(sources) def make_site(chapters): SITE.mkdir(exist_ok=True); files=list(chapters)+[MD/'NaturalHazards.Appendices.md']; known={p.name for p in files}; files += [p for p in sorted(MD.glob('*.md')) if p.name not in known] books=[] for p in files: if not p.exists(): continue title=p.stem.replace('NaturalHazards.','').replace('_',' ') if title.startswith('Natural hazards'): title='Full Book — Natural Hazards' title=re.sub(r'Chapter(\d+)',r'Chapter \1',title) title=re.sub(r'(?<=[a-z])(?=[A-Z])',' ',title).replace('.',' — ') books.append({'title':title,'path':'../'+p.relative_to(ROOT).as_posix()}) (SITE/'books.json').write_text(json.dumps(books,indent=2)+'\n'); (SITE/'index.html').write_text(HTML); (SITE/'app.js').write_text(APP); (SITE/'style.css').write_text(CSS); (SITE/'README.md').write_text('Serve the book directory over HTTP, then open `http://localhost:8000/site/`:\n\n```sh\npython3 -m http.server 8000\n```\n') HTML='''Natural Hazards — Book Browser
Select a page

Natural Hazards

Choose a Markdown page from the list.

''' APP=r'''const nav=document.querySelector('#nav'),content=document.querySelector('#content'),filter=document.querySelector('#filter');let books=[]; fetch('books.json').then(r=>r.json()).then(x=>{books=x;draw()}); function draw(){nav.innerHTML='';books.filter(b=>b.title.toLowerCase().includes(filter.value.toLowerCase())).forEach(b=>{const a=document.createElement('a');a.href='#';a.textContent=b.title;a.onclick=e=>{e.preventDefault();load(b.path)};nav.append(a)})} filter.oninput=draw;document.querySelector('#menu').onclick=()=>document.body.classList.toggle('show-nav'); async function load(path){try{const r=await fetch(path);if(!r.ok)throw Error();const md=await r.text();document.querySelector('#where').textContent=books.find(b=>b.path===path)?.title||path;content.innerHTML=render(md,new URL(path,location.href));document.body.classList.remove('show-nav')}catch(e){content.innerHTML='

Could not load this page. Run python3 -m http.server from the book directory.

'}} function inl(s,b){s=s.replace(/&/g,'&').replace(//g,'>');return s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g,(_,t,u)=>''+t+'').replace(/\[([^\]]+)\]\(([^)]+)\)/g,(_,t,u)=>''+t+'').replace(/\*\*(.+?)\*\*/g,'$1').replace(/\*(.+?)\*/g,'$1')} function render(md,b){let out=[],p=[],list=false,table=[];function flush(){if(p.length){out.push('

'+p.map(x=>inl(x,b)).join(' ')+'

');p=[]}if(list){out.push('');list=false}if(table.length){let rs=table.filter(x=>!/^\|?\s*:?-{3,}/.test(x));out.push(''+rs.map((r,i)=>''+r.trim().replace(/^\||\|$/g,'').split('|').map(c=>'<'+(i?'td':'th')+'>'+inl(c.trim(),b)+'').join('')+'').join('')+'
');table=[]}} for(const l of md.split(/\r?\n/)){if(/^\s*\|.*\|\s*$/.test(l)){if(p.length||list)flush();table.push(l);continue}if(table.length)flush();let m=l.match(/^(#{1,6})\s+(.*)$/);if(m){flush();out.push(''+inl(m[2],b)+'');continue}m=l.match(/^\s*[-*+]\s+(.*)$/);if(m){if(p.length)flush();if(!list){out.push('