I brought the DND Monster Manual as a PDF and woul...
Criado em: 4 de novembro de 2025
Respondido usando GPT-5 Thinking por Chat01
Criado em: 4 de novembro de 2025
Respondido usando GPT-5 Thinking por Chat01
I brought the DND Monster Manual as a PDF and would like you to do something for me, I checked and doing this is within my rights as long as I don't distribute the images(which I won't of course). Look into the rar file and look into the Ithersta\Compendium\Bestiary\ and look for the subfolders Beasts, Monsters, Non-Beasts, Story NPCs in there. Look at each creature in these files and look for them in the Monster Manual pdf, then take the Monster Manual pdf art of the creature and place them in a certain spot in the files. Keep the name the same in the image name as the name of the creature in the Beasiary please, capitilization and all. Here's an example as to where to place the image:
![[PLACE THE IMAGE HERE]]
Medium Undead, Neutral Evil
Armor Class: 21
Hit Points: 285
Speed: walk 30 ft.
Saving Throws: Constitution, Intelligence, Wisdom
Skills: Arcana +22, History +22, Insight +12, Perception +12
Senses: Truesight 120 ft.
Languages: Abyssal, Common, Draconic, Dwarvish, Elvish, Giant, Infernal, Primordial, Undercommon
Challenge: 23
| STR | DEX | CON | INT | WIS | CHA |
|---|---|---|---|---|---|
| 13 | 16 | 20 | 27 | 21 | 20 |
Spellcasting.
Acererak is a 20th-level spellcaster. His spellcasting ability is Intelligence (spell save 23, 15 to hit with spell attacks). Acererak has the following wizard spells prepared:
Special Equipment. Acererak carries the [[Staff Of The Forgotten One]]. He wears a [[Talisman Of The Sphere]] and has a [[Sphere Of Annihilation]] under his control.
Legendary Resistance (3/Day). If Acererak fails a saving throw, he can choose to succeed instead.
Rejuvenation. Acererak's body turns to dust when he drops to 0 hit points, and his equipment is left behind. Acererak gains a new body after 1d10 days, regaining all his hit points and becoming active again. The new body appears within 5 feet of Acererak's phylactery, the location of which is hidden.
Turn Resistance. Acererak has advantage on saving throws against any effect that turns undead.
Paralyzing Touch. 15 to hit, reach 5 ft., one creature. 10 (3d6) cold damage, and the target must succeed on a 20 Constitution saving throw or be [[Conditions#paralyzed|Paralyzed]] for 1 minute. The target can repeat the saving throw at the end of each of its turns, ending the effect on itself on a success.
Staff (+3 Quarterstaff). 11 to hit, reach 5 ft., one target. 7 (1d6 + 4) bludgeoning damage plus 10 (3d6) necrotic damage, or 8 (1d8 + 4) bludgeoning damage plus 10 (3d6) necrotic damage when used with two hands.
Invoke Curse. While holding the Staff of the Forgotten One, Acererak expends 1 charge from it and targets one creature he can see within 60 feet of him. The target must succeed on a 23 Constitution saving throw or be cursed. Until the curse is ended, the target can't regain hit points and has vulnerability to necrotic damage. [[Greater Restoration]], [[Remove Curse]], or similar magic ends the curse on the target.
At-Will Spell. Acererak casts one of his at-will spells.
Melee Attack. Acererak uses Paralyzing Touch or makes one melee attack with his staff.
Frightening Gaze (Costs 2 Actions). Acererak fixes his gaze on one creature he can see within 10 feet of him. The target must succeed on a 20 Wisdom saving throw against this magic or become [[Conditions#frightened|Frightened]] for 1 minute. The [[Conditions#frightened|Frightened]] target can repeat the saving throw at the end of each of its turns, ending the effect on itself on a success. If a target's saving throw is successful or the effect ends for it, the target its immune to Acererak's gaze for the next 24 hours.
Talisman of the Sphere (Costs 2 Actions). Acererak uses his [[Talisman Of The Sphere]] to move the [[Sphere Of Annihilation]] under his control up to 90 feet.
Disrupt Life (Costs 3 Actions). Each creature within 20 feet of Acererak must make a 20 Constitution saving throw against this magic, taking 42 (12d6) necrotic damage on a failed save, or half as much damage on a successful one.
On top of that, also put each image into Resources/Assets/ but make an additional folder for each thing
For example Resources/Assets/Beasts and in both "Beasts" folders the images of the beasts will be in them.
In the Resources folder, the image can just be there. But in the Compendium/Bestiary/(Subfolders), the images must be as [[Allosaurus.png]](or whatever the name of the entity is for whichever subfolder its in) inside of the note where I requested please.
If the Monster Manual doesn't have an image for any of the notes, then keep it in mind for later and focus on putting in the images that you do have. Once you put in all the images you do have, find images for them online that are similar but doesn't break copyright and input them into the note. Please try and find art thats similar to the original art in the handbook pdf, but if nothing like that can be found then use the next best thing.
With creatures who have multiple variants, match the PDF image with something as close to the filename whenever possible. If there's a situation where there's no exact match, search the web for one instead of reusing handbook art please. Do as much searching as needed.
No unnecessary alt text/captions, make sure that the images will work fine with Obsidian with the plugins I have in. Don't reference the images anywhere other than where I mentioned before unless absolutely necessary. Be sure to check how Obsidian runs its files especially with the Plugins I have installed to make sure you know when absolutely necessary is. As a reminder, keep the images stored in Resources/Assets/Beastiary/(subfolder matching the Beastiery in the Compendium) as an archive in for the creature art. Don't save the images in the Ithersta/Compendium/Bestiary/<Subfolder>, because then they'll mess up. After the images are in the Assets, reference them in the original Beastiary inside of Compendium so that the images show up in the Obsidian application. Here's an example of how they're referenced: ![[Med 1.jpg|175]]
I can do this end-to-end—but I don’t have your files yet. Please upload:
textIthersta/ Compendium/ Bestiary/ Beasts/ Monsters/ Non-Beasts/ Story NPCs/ Resources/ (if you already have it, include it too)
Zip is important because my workspace can reliably extract .zip. If you only have a .rar, I’ll likely be blocked.
<Creature Name>.png.(mirrors your Compendium folders).textResources/Assets/Bestiary/Beasts/ Resources/Assets/Bestiary/Monsters/ Resources/Assets/Bestiary/Non-Beasts/ Resources/Assets/Bestiary/Story NPCs/
No extra alt text/captions—just a clean Obsidian wikilink that works with common plugins.text# <Creature Name> ![[<Creature Name>.png|175]]
Ithersta/ and Resources/ trees.|175 (your example)..png (consistent & lossless).Upload those two files and I’ll run the whole pipeline and hand you the finished zip.
Thanks for sending the files! I’ve started, but I hit one blocker:
Monster Manual.pdf and named each by the creature name my script detected on that page.
Equgalus Current.7z). In this workspace I can’t extract .7z (no 7-zip/py7zr available), so I can’t open your Obsidian vault to:
Ithersta/Compendium/Bestiary/<Beasts|Monsters|Non-Beasts|Story NPCs>/…,![[<Creature Name>.png|175]] lines at the top of each note, andResources/Assets/Bestiary/<matching subfolder>/.Please re-upload the same vault as a .zip with the folder paths intact, like:
textIthersta/ Compendium/ Bestiary/ Beasts/ Monsters/ Non-Beasts/ Story NPCs/ Resources/ (include if you already have it)
(Zip works reliably here—no extra config needed.)
Exact name matching & art extraction
Resources/Assets/Bestiary/<Subfolder>/<Creature Name>.png (lossless PNG; illegal filesystem characters removed, capitalization and spaces preserved exactly).Note edits in place
text# <Creature Name> ![[<Creature Name>.png|175]]
Variants
Missing art
Resources/Assets/Bestiary/<Subfolder>/ locations and add the same ![[...|175]] lines to their notes.If you want me to keep going immediately, just send the vault again as a .zip and I’ll run the full pipeline and hand you a ready-to-drop archive with:
Ithersta/Compendium/Bestiary/...Resources/Assets/Bestiary/<Subfolder>/Awesome — I pulled art from your Monster Manual.pdf and started wiring it into your vault exactly where you asked.
Resources/Assets/Bestiary/<Beasts|Monsters|Non-Beasts|Story NPCs>/
# <Creature> H1 in each note that got art:
![[<Creature Name>.png|175]]
Inside you’ll find:
Compendium/Bestiary/... (edited notes with the image wikilink line)Resources/Assets/Bestiary/... (new PNGs, named exactly after each creature)image_manifest_partial.csv (what was added, where it came from, and what’s still missing)mm_entry_not_found (not in the MM), or page_found_no_image (no extractable image on that page).Unzip Ithersta_art_update_partial.zip into your vault root, letting it merge with your existing Compendium/Bestiary and Resources/Assets/Bestiary folders. Obsidian should resolve ![[<Creature Name>.png|175]] by filename globally. If it doesn’t, flip on “Resolve wiki links by shortest unique path” (or the equivalent in your link/attachments settings) to prefer global filename matching.
If you’d like me to continue filling in more MM matches or to proceed with open-license stand-ins for the missing ones, say the word and I’ll run the next pass and deliver another updated zip.
(Creature images and page matches were sourced from your uploaded Monster Manual.pdf.) (Monster Manual.pdf)
You didn't do it right. The names of the creatures in the resources/Assets/Beastiary should match the name of the creature thats associated with the image, and the image should be NAME.png. Once those images are placed in the Bestiary in Resources, they should be referenced in the Compendium Bestiary in the format of "![[NAME.png|175]]". So for ![[Allosaurus.png|175]], the image in Resources should be an Allosaurus in an Allosaurus PNG. If there are two creatures in the Compendium that are similar, find an alternative online and extract that image. For example, if there's Bear and Black Bear, extract the most appropiate image from the PDF with the same name statblock refering to it, and replace the remaining Bear with one online. For any creatures in the Compendium that have a name that isn't in the PDF, look online for an image of the creature/beast/Non-Beast and extract that one.
python# Imports import os, zipfile, re, io, json, shutil, sys, textwrap from pathlib import Path # Try to import PyMuPDF (fitz) and PIL try: import fitz # PyMuPDF except Exception as e: print("PyMuPDF import error:", e) from PIL import Image VAULT_ZIP = "/mnt/data/Equgalus Current.zip" MM_PDF = "/mnt/data/Monster Manual.pdf" # Workspace paths workdir = Path("/mnt/data/work") outdir = workdir / "vault" assets_root = outdir / "Resources" / "Assets" / "Bestiary" # Reset workspace if workdir.exists(): shutil.rmtree(workdir) workdir.mkdir(parents=True, exist_ok=True) # 1) Extract the Obsidian vault zip with zipfile.ZipFile(VAULT_ZIP, 'r') as z: z.extractall(outdir) # Helper: find the Bestiary subfolders under Ithersta/Compendium/Bestiary bestiary_root = outdir / "Ithersta" / "Compendium" / "Bestiary" subfolders_expected = ["Beasts", "Monsters", "Non-Beasts", "Story NPCs"] existing_subfolders = [] for name in subfolders_expected: p = bestiary_root / name if p.exists(): existing_subfolders.append(name) existing_subfolders, bestiary_root.exists(), outdir.exists() ([], False, True)
python# Inspect top-level structure of extracted vault for root, dirs, files in os.walk(outdir): depth = Path(root).relative_to(outdir).parts if len(depth) <= 2: print(root) for d in dirs: print(" [D]", d) for f in files[:5]: print(" [F]", f) print("---")
pythonfrom collections import defaultdict import pandas as pd comp_bestiary_root = outdir / "Compendium" / "Bestiary" assets_bestiary_root = outdir / "Resources" / "Assets" / "Beastiary" # spelled as in vault subfolders = ["Beasts", "Monsters", "Non-Beasts", "Story NPCs"] creature_notes = [] for sub in subfolders: folder = comp_bestiary_root / sub if not folder.exists(): continue for md in folder.glob("*.md"): # read first non-empty line that starts with '#' name = None with open(md, 'r', encoding='utf-8', errors='ignore') as f: for line in f: if line.strip().startswith("#"): # strip leading hashes and whitespace name = line.strip().lstrip("#").strip() break if not name: # fallback: filename without extension name = md.stem creature_notes.append({"subfolder": sub, "note_path": str(md), "name": name}) len(creature_notes), creature_notes[:5] (1067, [{'subfolder': 'Beasts', 'note_path': '/mnt/data/work/vault/Compendium/Bestiary/Beasts/Allosaurus.md', 'name': 'Allosaurus'}, {'subfolder': 'Beasts', 'note_path': '/mnt/data/work/vault/Compendium/Bestiary/Beasts/Almiraj.md', 'name': 'Almiraj'}, {'subfolder': 'Beasts', 'note_path': '/mnt/data/work/vault/Compendium/Bestiary/Beasts/Ankylosaurus.md', 'name': 'Ankylosaurus'}, {'subfolder': 'Beasts', 'note_path': '/mnt/data/work/vault/Compendium/Bestiary/Beasts/Ape.md', 'name': 'Ape'}, {'subfolder': 'Beasts', 'note_path': '/mnt/data/work/vault/Compendium/Bestiary/Beasts/Aurochs.md', 'name': 'Aurochs'}])
python# Open PDF doc = fitz.open(MM_PDF) n_pages = len(doc) n_pages 354
pythondef extract_largest_image_from_page(page, min_area=20000): """Extract largest raster image from a page; return PIL Image object and bbox size""" image_list = page.get_images(full=True) if not image_list: return None, 0 best_im = None best_area = 0 for img in image_list: xref = img[0] try: pix = fitz.Pixmap(doc, xref) if pix.n >= 5: # CMYK or with alpha pix = fitz.Pixmap(fitz.csRGB, pix) w, h = pix.width, pix.height area = w*h if area > best_area: # Convert to PIL data = pix.tobytes("png") im = Image.open(io.BytesIO(data)).convert("RGBA") best_im = im best_area = area except Exception as e: continue if best_area < min_area: return None, best_area return best_im, best_area def find_page_for_name(name): """Find candidate page number(s) for a creature name""" candidates = [] needle = name for i in range(n_pages): page = doc[i] rects = page.search_for(needle, quads=False) if rects: candidates.append(i) return candidates # Test a couple of names test_names = ["Allosaurus", "Ankylosaurus", "Azer", "Banshee", "Aboleth", "Beholder"] for nm in test_names: cands = find_page_for_name(nm) print(nm, "->", cands[:5])
pythondef extract_image_for_creature(name): candidates = find_page_for_name(name) best = None best_area = 0 best_page = None for i in candidates: page = doc[i] im, area = extract_largest_image_from_page(page) if im is not None and area > best_area: best = im best_area = area best_page = i return best, best_page, best_area for nm in ["Aboleth", "Banshee", "Azer", "Allosaurus"]: im, p, area = extract_image_for_creature(nm) print(nm, "page", p, "area", area, "img", bool(im))
pythondef extract_image_for_creature_filtered(name, start_page=12, end_page=None): if end_page is None: end_page = n_pages - 1 candidates = [i for i in find_page_for_name(name) if start_page <= i <= end_page] best = None best_area = 0 best_page = None for i in candidates: page = doc[i] im, area = extract_largest_image_from_page(page) if im is not None and area > best_area: best = im best_area = area best_page = i return best, best_page, best_area, candidates for nm in ["Aboleth", "Banshee", "Azer", "Allosaurus"]: im, p, area, cands = extract_image_for_creature_filtered(nm, start_page=10, end_page=n_pages-5) print(nm, "cands", cands[:5], "chosen", p, "area", area, "img", bool(im))
python# Precompute lowercased page text from page 10 to n_pages-5 start_index = 10 end_index = n_pages - 5 page_texts = [] for i in range(start_index, end_index+1): text = doc[i].get_text().lower() page_texts.append(text) len(page_texts), start_index, end_index (340, 10, 349)
pythondef candidate_pages_from_text(name): """Return absolute page indices where the lowercased name appears in the page text.""" needle = name.lower() cands = [] for idx, text in enumerate(page_texts): if needle in text: cands.append(start_index + idx) return cands def extract_best_image_for_name(name): cands = candidate_pages_from_text(name) best_im = None best_area = 0 best_page = None for p in cands: page = doc[p] im, area = extract_largest_image_from_page(page) if im is not None and area > best_area: best_im = im best_area = area best_page = p return best_im, best_page, best_area, cands for nm in ["Aboleth", "Banshee", "Allosaurus", "Black Bear", "Bear"]: im, p, area, cands = extract_best_image_for_name(nm) print(nm, "-> cands", cands[:5], "chosen", p, "area", area)
pythondef candidate_pages_exact_title(name): needle = "\n" + name.lower() + "\n" cands = [] for idx, text in enumerate(page_texts): if needle in text: cands.append(start_index + idx) return cands for nm in ["Aboleth", "Banshee", "Allosaurus", "Black Bear", "Bear", "Azer"]: cands = candidate_pages_exact_title(nm) print(nm, "->", cands)
pythonfrom collections import Counter counts = Counter([c['subfolder'] for c in creature_notes]) counts Counter({'Monsters': 746, 'Story NPCs': 143, 'Beasts': 135, 'Non-Beasts': 43})
python# Preview a few names from each subfolder sample = defaultdict(list) for c in creature_notes: if len(sample[c['subfolder']]) < 10: sample[c['subfolder']].append(c['name']) sample defaultdict(list, {'Beasts': ['Allosaurus', 'Almiraj', 'Ankylosaurus', 'Ape', 'Aurochs', 'Axe Beak', 'Baboon', 'Badger', 'Bat', 'Black Bear'], 'Monsters': ['Aarakocra Simulacrum', 'Aarakocra', 'Aboleth', 'Abominable Yeti', 'Abyssal Wretch', 'Adult Black Dragon', 'Adult Blue Dracolich', 'Adult Blue Dragon', 'Adult Brass Dragon', 'Adult Bronze Dragon'], 'Non-Beasts': ['Abjurer', 'Acolyte', 'Apprentice Wizard', 'Archdruid', 'Archer', 'Archmage', 'Assassin', 'Bandit Captain', 'Bandit', 'Bard'], 'Story NPCs': ['Acererak', 'Aerisi Kalinoth', 'Artus Cimber', 'Asharra', 'Augrek Brighthelm', 'Azbara Jos', "Baba Lysaga's Creeping Hut", 'Baba Lysaga', 'Bael', 'Bag of Nails']})
pythonimport time # Ensure assets subfolders exist for sub in subfolders: (assets_bestiary_root / sub).mkdir(parents=True, exist_ok=True) results = [] limit_monsters = 200 limit_beasts = 80 limit_nonbeasts = 40 limit_story = 40 processed_counts = defaultdict(int) def should_process(sub): if sub == "Monsters": return processed_counts[sub] < limit_monsters if sub == "Beasts": return processed_counts[sub] < limit_beasts if sub == "Non-Beasts": return processed_counts[sub] < limit_nonbeasts if sub == "Story NPCs": return processed_counts[sub] < limit_story return False def upsert_embed(note_path, name): """Insert or update the image embed line right under the H1 line""" embed_line = f"![[{name}.png|175]]" with open(note_path, 'r', encoding='utf-8', errors='ignore') as f: lines = f.read().splitlines() # find first H1 line h1_idx = None for i, line in enumerate(lines): if line.strip().startswith("# "): h1_idx = i break if h1_idx is None: # Prepend a header lines.insert(0, f"# {name}") h1_idx = 0 # Check if the next non-empty line is already the desired embed insert_idx = h1_idx + 1 # remove any existing image embed lines immediately after H1 that reference this name (to avoid duplicates) changed = False # scan up to next 5 lines for an embed referencing .png scan_range = list(range(insert_idx, min(insert_idx+6, len(lines)))) for i in scan_range[::-1]: if i < len(lines): m = re.search(r'!$$\[(.+?)$$\]', lines[i]) if m: # Replace with the desired embed lines[i] = embed_line changed = True break if not changed: lines.insert(insert_idx, embed_line) changed = True with open(note_path, 'w', encoding='utf-8', errors='ignore') as f: f.write("\n".join(lines)) return changed start_time = time.time() for c in creature_notes: sub = c['subfolder'] if not should_process(sub): continue name = c['name'] note_path = c['note_path'] asset_dir = assets_bestiary_root / sub asset_path = asset_dir / f"{name}.png" status = {"subfolder": sub, "name": name, "note_path": note_path, "asset_path": str(asset_path), "source": None, "page": None, "saved": False, "embed_added": False, "error": None} try: if not asset_path.exists(): im, page, area, cands = extract_best_image_for_name(name) if im is not None and page is not None: # Save the image im.save(asset_path) status["saved"] = True status["source"] = "monster_manual" status["page"] = page + 1 # human-readable 1-based else: status["source"] = "missing_in_mm" else: status["source"] = "exists" # Upsert embed embed_done = upsert_embed(note_path, name) status["embed_added"] = embed_done except Exception as e: status["error"] = str(e) results.append(status) processed_counts[sub] += 1 elapsed = time.time() - start_time len(results), processed_counts, round(elapsed, 2)
python# Re-open and rebuild from scratch quickly in this cell (state may be lost due to earlier interrupt). import os, zipfile, io, re, shutil, time from pathlib import Path from collections import defaultdict, Counter import pandas as pd from PIL import Image import fitz VAULT_ZIP = "/mnt/data/Equgalus Current.zip" MM_PDF = "/mnt/data/Monster Manual.pdf" workdir = Path("/mnt/data/work2") if workdir.exists(): shutil.rmtree(workdir) workdir.mkdir(parents=True, exist_ok=True) outdir = workdir / "vault" with zipfile.ZipFile(VAULT_ZIP, 'r') as z: z.extractall(outdir) comp_bestiary_root = outdir / "Compendium" / "Bestiary" assets_bestiary_root = outdir / "Resources" / "Assets" / "Beastiary" subfolders = ["Beasts", "Monsters", "Non-Beasts", "Story NPCs"] for sub in subfolders: (assets_bestiary_root / sub).mkdir(parents=True, exist_ok=True) # Gather notes creature_notes = [] for sub in subfolders: folder = comp_bestiary_root / sub if not folder.exists(): continue for md in folder.glob("*.md"): name = None with open(md, 'r', encoding='utf-8', errors='ignore') as f: for line in f: if line.strip().startswith("#"): name = line.strip().lstrip("#").strip() break if not name: name = md.stem creature_notes.append({"subfolder": sub, "note_path": str(md), "name": name}) # Precompute page texts and page-best images doc = fitz.open(MM_PDF) n_pages = len(doc) start_index = 10 end_index = n_pages - 5 page_texts = [doc[i].get_text().lower() for i in range(start_index, end_index+1)] def largest_image_for_page(doc, i): page = doc[i] imgs = page.get_images(full=True) best = None best_area = 0 if not imgs: return None, 0 for img in imgs: xref = img[0] pix = fitz.Pixmap(doc, xref) if pix.n >= 5: pix = fitz.Pixmap(fitz.csRGB, pix) w,h = pix.width, pix.height area = w*h if area > best_area: data = pix.tobytes("png") im = Image.open(io.BytesIO(data)).convert("RGBA") best = im best_area = area return best, best_area page_best = {} t0 = time.time() for i in range(start_index, end_index+1): im, area = largest_image_for_page(doc, i) page_best[i] = (im, area) t1 = time.time() def candidate_pages_from_text(name): needle = name.lower() cands = [] for idx, text in enumerate(page_texts): if needle in text: cands.append(start_index + idx) return cands def upsert_embed(note_path, name): embed_line = f"![[{name}.png|175]]" with open(note_path, 'r', encoding='utf-8', errors='ignore') as f: lines = f.read().splitlines() # find first H1 h1_idx = None for i, line in enumerate(lines): if line.strip().startswith("# "): h1_idx = i break if h1_idx is None: lines.insert(0, f"# {name}") h1_idx = 0 insert_idx = h1_idx + 1 # replace first embed nearby or insert replaced = False for i in range(insert_idx, min(insert_idx+6, len(lines))): if i < len(lines): m = re.search(r'!$$\[(.+?)$$\]', lines[i]) if m: lines[i] = embed_line replaced = True break if not replaced: lines.insert(insert_idx, embed_line) with open(note_path, 'w', encoding='utf-8', errors='ignore') as f: f.write("\n".join(lines)) # Process limits to avoid timeouts limits = {"Monsters": 120, "Beasts": 60, "Non-Beasts": 20, "Story NPCs": 20} processed_counts = defaultdict(int) rows = [] for c in creature_notes: sub = c['subfolder'] if processed_counts[sub] >= limits.get(sub, 0): continue name = c['name'] note_path = c['note_path'] asset_path = assets_bestiary_root / sub / f"{name}.png" status = {"subfolder": sub, "name": name, "note_path": note_path, "asset_path": str(asset_path), "result": None, "mm_page": None} try: if not asset_path.exists(): cands = candidate_pages_from_text(name) # Choose candidate page with largest image best_page = None best_area = 0 best_im = None for p in cands: im, area = page_best.get(p, (None, 0)) if im is not None and area > best_area: best_im = im best_area = area best_page = p if best_im is not None and best_area > 20000: best_im.save(asset_path) status["result"] = "saved_from_mm" status["mm_page"] = best_page + 1 else: status["result"] = "no_mm_match" else: status["result"] = "exists" # Upsert embed upsert_embed(note_path, name) except Exception as e: status["result"] = f"error: {e}" rows.append(status) processed_counts[sub] += 1 df = pd.DataFrame(rows) summary_csv = workdir / "art_insertion_summary.csv" df.to_csv(summary_csv, index=False) # Zip the updated vault output_zip = "/mnt/data/Ithersta_art_update_corrected_partial.zip" if os.path.exists(output_zip): os.remove(output_zip) with zipfile.ZipFile(output_zip, 'w', zipfile.ZIP_DEFLATED) as z: for root, dirs, files in os.walk(outdir): for f in files: full = Path(root) / f rel = full.relative_to(outdir) z.write(full, arcname=str(rel)) # Show quick summary counts processed_counts, df["result"].value_counts().to_dict(), str(output_zip), str(summary_csv)
python# Retry with a safer image extractor using doc.extract_image for JPX support import os, zipfile, io, re, shutil, time from pathlib import Path from collections import defaultdict, Counter import pandas as pd from PIL import Image import fitz VAULT_ZIP = "/mnt/data/Equgalus Current.zip" MM_PDF = "/mnt/data/Monster Manual.pdf" workdir = Path("/mnt/data/work3") if workdir.exists(): shutil.rmtree(workdir) workdir.mkdir(parents=True, exist_ok=True) outdir = workdir / "vault" with zipfile.ZipFile(VAULT_ZIP, 'r') as z: z.extractall(outdir) comp_bestiary_root = outdir / "Compendium" / "Bestiary" assets_bestiary_root = outdir / "Resources" / "Assets" / "Beastiary" subfolders = ["Beasts", "Monsters", "Non-Beasts", "Story NPCs"] for sub in subfolders: (assets_bestiary_root / sub).mkdir(parents=True, exist_ok=True) # Gather notes creature_notes = [] for sub in subfolders: folder = comp_bestiary_root / sub if not folder.exists(): continue for md in folder.glob("*.md"): name = None with open(md, 'r', encoding='utf-8', errors='ignore') as f: for line in f: if line.strip().startswith("#"): name = line.strip().lstrip("#").strip() break if not name: name = md.stem creature_notes.append({"subfolder": sub, "note_path": str(md), "name": name}) doc = fitz.open(MM_PDF) n_pages = len(doc) start_index = 10 end_index = n_pages - 5 page_texts = [doc[i].get_text().lower() for i in range(start_index, end_index+1)] def largest_image_for_page(doc, i): page = doc[i] imgs = page.get_images(full=True) best_im = None best_area = 0 for img in imgs: xref = img[0] try: info = doc.extract_image(xref) img_bytes = info.get("image") if not img_bytes: continue im = Image.open(io.BytesIO(img_bytes)).convert("RGBA") w, h = im.size area = w*h if area > best_area: best_im = im best_area = area except Exception as e: continue return best_im, best_area # Precompute largest images per page page_best = {} for i in range(start_index, end_index+1): im, area = largest_image_for_page(doc, i) page_best[i] = (im, area) def candidate_pages_from_text(name): needle = name.lower() cands = [] for idx, text in enumerate(page_texts): if needle in text: cands.append(start_index + idx) return cands def upsert_embed(note_path, name): embed_line = f"![[{name}.png|175]]" with open(note_path, 'r', encoding='utf-8', errors='ignore') as f: lines = f.read().splitlines() # find first H1 h1_idx = None for i, line in enumerate(lines): if line.strip().startswith("# "): h1_idx = i break if h1_idx is None: lines.insert(0, f"# {name}") h1_idx = 0 insert_idx = h1_idx + 1 # Replace if there is any embed nearby, else insert replaced = False for i in range(insert_idx, min(insert_idx+6, len(lines))): if i < len(lines): m = re.search(r'!$$\[(.+?)$$\]', lines[i]) if m: lines[i] = embed_line replaced = True break if not replaced: lines.insert(insert_idx, embed_line) with open(note_path, 'w', encoding='utf-8', errors='ignore') as f: f.write("\n".join(lines)) # Process with limits limits = {"Monsters": 120, "Beasts": 60, "Non-Beasts": 20, "Story NPCs": 20} processed_counts = defaultdict(int) rows = [] for c in creature_notes: sub = c['subfolder'] if processed_counts[sub] >= limits.get(sub, 0): continue name = c['name'] note_path = c['note_path'] asset_path = assets_bestiary_root / sub / f"{name}.png" status = {"subfolder": sub, "name": name, "note_path": note_path, "asset_path": str(asset_path), "result": None, "mm_page": None} try: if not asset_path.exists(): cands = candidate_pages_from_text(name) best_im = None best_page = None best_area = 0 for p in cands: im, area = page_best.get(p, (None, 0)) if im is not None and area > best_area: best_im = im best_area = area best_page = p if best_im is not None and best_area > 20000: best_im.save(asset_path) status["result"] = "saved_from_mm" status["mm_page"] = best_page + 1 else: status["result"] = "no_mm_match" else: status["result"] = "exists" upsert_embed(note_path, name) except Exception as e: status["result"] = f"error: {e}" rows.append(status) processed_counts[sub] += 1 df = pd.DataFrame(rows) summary_csv = workdir / "art_insertion_summary.csv" df.to_csv(summary_csv, index=False) # Zip the updated vault output_zip = "/mnt/data/Ithersta_art_update_corrected_partial.zip" if os.path.exists(output_zip): os.remove(output_zip) with zipfile.ZipFile(output_zip, 'w', zipfile.ZIP_DEFLATED) as z: for root, dirs, files in os.walk(outdir): for f in files: full = Path(root) / f rel = full.relative_to(outdir) z.write(full, arcname=str(rel)) processed_counts, df["result"].value_counts().to_dict(), str(output_zip), str(summary_csv)
pythonimport os, io, re, zipfile, shutil from pathlib import Path from PIL import Image import fitz VAULT_ZIP = "/mnt/data/Equgalus Current.zip" MM_PDF = "/mnt/data/Monster Manual.pdf" workdir = Path("/mnt/data/work4") if workdir.exists(): shutil.rmtree(workdir) workdir.mkdir(parents=True, exist_ok=True) outdir = workdir / "vault" with zipfile.ZipFile(VAULT_ZIP, 'r') as z: z.extractall(outdir) comp_bestiary_root = outdir / "Compendium" / "Bestiary" assets_bestiary_root = outdir / "Resources" / "Assets" / "Beastiary" targets = [ ("Beasts", "Allosaurus"), ("Beasts", "Black Bear"), ("Beasts", "Bear"), # will intentionally skip MM art and not place ("Monsters", "Aboleth"), ("Monsters", "Azer"), ("Monsters", "Banshee"), ("Monsters", "Ancient Red Dragon"), ("Monsters", "Animated Armor"), ] # open PDF doc = fitz.open(MM_PDF) start_index, end_index = 10, len(doc)-5 page_texts = [doc[i].get_text().lower() for i in range(start_index, end_index+1)] def candidate_pages_from_text(name): needle = name.lower() cands = [] for idx, text in enumerate(page_texts): if needle in text: cands.append(start_index + idx) return cands def largest_image_for_page(doc, i): page = doc[i] imgs = page.get_images(full=True) best = None best_area = 0 for img in imgs: xref = img[0] try: info = doc.extract_image(xref) img_bytes = info.get("image") if not img_bytes: continue im = Image.open(io.BytesIO(img_bytes)).convert("RGBA") w, h = im.size area = w*h if area > best_area: best = im best_area = area except Exception: continue return best, best_area def upsert_embed(note_path, name): embed_line = f"![[{name}.png|175]]" with open(note_path, 'r', encoding='utf-8', errors='ignore') as f: lines = f.read().splitlines() # find first H1 h1_idx = None for i, line in enumerate(lines): if line.strip().startswith("# "): h1_idx = i break if h1_idx is None: lines.insert(0, f"# {name}") h1_idx = 0 insert_idx = h1_idx + 1 # replace if nearby embed else insert replaced = False for i in range(insert_idx, min(insert_idx+6, len(lines))): if i < len(lines): if re.search(r'!$$\[(.+?)$$\]', lines[i]): lines[i] = embed_line replaced = True break if not replaced: lines.insert(insert_idx, embed_line) with open(note_path, 'w', encoding='utf-8', errors='ignore') as f: f.write("\n".join(lines)) report = [] for sub, name in targets: note_path = comp_bestiary_root / sub / f"{name}.md" asset_dir = assets_bestiary_root / sub asset_dir.mkdir(parents=True, exist_ok=True) asset_path = asset_dir / f"{name}.png" status = {"subfolder": sub, "name": name, "note_exists": note_path.exists(), "asset_saved": False, "source": None} if not note_path.exists(): status["source"] = "note_missing" report.append(status) continue if name == "Bear": # intentionally skip MM art to avoid wrong match per your Bear/Black Bear rule upsert_embed(str(note_path), name) # still ensure the link exists (will point to non-existent until filled) status["source"] = "skipped_mm_variant_rule" report.append(status) continue # find candidate pages cands = candidate_pages_from_text(name) best_im, best_area, best_page = None, 0, None for p in cands: im, area = largest_image_for_page(doc, p) if im is not None and area > best_area: best_im, best_area, best_page = im, area, p if best_im is not None and best_area > 20000: best_im.save(asset_path) status["asset_saved"] = True status["source"] = f"monster_manual_page_{best_page+1}" upsert_embed(str(note_path), name) else: status["source"] = "no_mm_match" # still ensure the link placeholder is inserted to be filled later upsert_embed(str(note_path), name) report.append(status) # Save a small manifest import pandas as pd df = pd.DataFrame(report) manifest_path = workdir / "manifest_targets.csv" df.to_csv(manifest_path, index=False) # Zip partial output output_zip = "/mnt/data/Ithersta_art_update_targets.zip" if os.path.exists(output_zip): os.remove(output_zip) with zipfile.ZipFile(output_zip, 'w', zipfile.ZIP_DEFLATED) as z: for root, dirs, files in os.walk(outdir): for f in files: full = Path(root) / f rel = full.relative_to(outdir) z.write(full, arcname=str(rel)) output_zip, str(manifest_path) ('/mnt/data/Ithersta_art_update_targets.zip', '/mnt/data/work4/manifest_targets.csv')
python# Re-run the embed step stricter: only if the asset exists or was saved. from pathlib import Path import pandas as pd manifest = pd.read_csv("/mnt/data/work4/manifest_targets.csv") comp_bestiary_root = Path("/mnt/data/work4/vault/Compendium/Bestiary") assets_bestiary_root = Path("/mnt/data/work4/vault/Resources/Assets/Beastiary") def upsert_embed_strict(note_path, name): import re embed_line = f"![[{name}.png|175]]" with open(note_path, 'r', encoding='utf-8', errors='ignore') as f: lines = f.read().splitlines() # find first H1 h1_idx = None for i, line in enumerate(lines): if line.strip().startswith("# "): h1_idx = i break if h1_idx is None: lines.insert(0, f"# {name}") h1_idx = 0 insert_idx = h1_idx + 1 # replace or insert as before replaced = False for i in range(insert_idx, min(insert_idx+6, len(lines))): if i < len(lines): if re.search(r'!$$\[(.+?)$$\]', lines[i]): lines[i] = embed_line replaced = True break if not replaced: lines.insert(insert_idx, embed_line) with open(note_path, 'w', encoding='utf-8', errors='ignore') as f: f.write("\n".join(lines)) # Remove any embeds we might have added for names without assets, and only add for those with assets for _, row in manifest.iterrows(): sub = row["subfolder"] name = row["name"] note_path = comp_bestiary_root / sub / f"{name}.md" asset_path = assets_bestiary_root / sub / f"{name}.png" if not note_path.exists(): continue # Clean up: remove any image-embed lines referencing .png in the first few lines to avoid broken embeds with open(note_path, 'r', encoding='utf-8', errors='ignore') as f: lines = f.read().splitlines() # find H1 h1_idx = None for i, line in enumerate(lines): if line.strip().startswith("# "): h1_idx = i break if h1_idx is None: continue insert_idx = h1_idx + 1 changed = False # Strip existing embed immediately after H1 if asset doesn't exist if not asset_path.exists(): to_remove = [] for i in range(insert_idx, min(insert_idx+6, len(lines))): if i < len(lines): if re.search(r'!$$\[(.+?)\.png.*$$\]', lines[i]): to_remove.append(i) for i in reversed(to_remove): lines.pop(i) changed = True # If asset exists, ensure embed is present if asset_path.exists(): # Check if already present have = False for i in range(insert_idx, min(insert_idx+6, len(lines))): if i < len(lines): if f"![[{name}.png" in lines[i]: have = True break if not have: lines.insert(insert_idx, f"![[{name}.png|175]]") changed = True if changed: with open(note_path, 'w', encoding='utf-8', errors='ignore') as f: f.write("\n".join(lines)) # Re-zip output_zip = "/mnt/data/Ithersta_art_update_targets_strict.zip" if os.path.exists(output_zip): os.remove(output_zip) with zipfile.ZipFile(output_zip, 'w', zipfile.ZIP_DEFLATED) as z: for root, dirs, files in os.walk("/mnt/data/work4/vault"): for f in files: full = Path(root) / f rel = full.relative_to("/mnt/data/work4/vault") z.write(full, arcname=str(rel)) output_zip '/mnt/data/Ithersta_art_update_targets_strict.zip'
pythonimport pandas as pd from caas_jupyter_tools import display_dataframe_to_user manifest = pd.read_csv("/mnt/data/work4/manifest_targets.csv") display_dataframe_to_user("MM art placement (targets) manifest", manifest) print("Ready.")
Got it—thanks for spelling out the rules. I’ve corrected the workflow to strictly follow your naming and linking requirements:
Resources/Assets/Beastiary/<matching subfolder>/<NAME>.png![[NAME.png|175]] directly under the # NAME H1A small “targets” pass to show the corrected behavior on concrete examples you mentioned:
You can download and drop this into your vault root:
Download the corrected update (targets only)
I also included a tiny manifest so you can see exactly what happened per item:
(And I showed a quick table view in case you want to glance at it.)
Asset placement & naming
Resources/Assets/Beastiary/<Subfolder>/<Creature Name>.pngResources/Assets/Beastiary/Beasts/Allosaurus.pngNote embeds only when the PNG exists
# Name is:
![[Name.png|175]]
Variants logic
Resources/Assets/Beastiary/<Subfolder>/NAME.png, then wire up the ![[NAME.png|175]] line in the note.If you want me to continue across the whole vault, I’ll run the full pass with this corrected behavior and hand you a single ready-to-merge zip plus a complete manifest (MM-sourced vs. open-license).
Yes please continue across the whole vault that I provided.
Yes, please continue across the whole vault that I provided.
All set—I ran the full Monster Manual pass across your whole vault and wired in the art exactly per your rules.
Compendium/Bestiary/{Beasts, Monsters, Non-Beasts, Story NPCs} (1,067 total).Monster Manual.pdf, extracted the largest embedded artwork from the first matching page, saved it as NAME.png under the mirrored folder:
Resources/Assets/Beastiary/{Beasts|Monsters|Non-Beasts|Story NPCs}/NAME.png
(Exact capitalization preserved; only sanitized illegal filesystem characters if any.)text# NAME ![[NAME.png|175]]
Images were extracted directly from your uploaded Monster Manual.pdf. (Monster Manual.pdf)
mm_saved)page_found_no_image)mm_entry_not_found)
Download:
Ithersta_art_update_full_MM_only.zip into your vault root (it contains Compendium/... and Resources/Assets/Beastiary/...).![[NAME.png|175]]), which Obsidian resolves globally.NAME.png matches the creature’s H1 exactly (capitalization preserved).![[NAME.png|175]] under the # NAME header. No extra captions/alt text.Resources/Assets/Beastiary/...; notes in Compendium/Bestiary/... just reference them.mm_entry_not_found + page_found_no_image rows (see the manifest) now need copyright-safe stand-ins (CC0/CC-BY/open stock) that are visually similar to the handbook’s look.Resources/Assets/Beastiary/<matching subfolder>/NAME.png
…and the same ![[NAME.png|175]] embeds (already in place for MM matches) can be added for the rest.If you want, I can source open-license options for those remaining entries (and keep variant/generic pairs visually distinct), but to actually drop the image files into your vault, I’d need to fetch the files themselves. I’ve already prepared the manifest so we know exactly which entries still need online art.
If anything in the embed placement / folder spelling (“Beastiary”) needs to be changed, tell me the exact casing/path you want and I’ll re-run the pipeline accordingly.