Fix HP/SAN track label bleed — BRP v4 and Stormbringer v3

Same root cause as LTCI/NAT: box top was rendering above label baseline.

BRP v4 (build_brp_characters_v4.py):
  BOX=20 → BOX=14, GAP=4 → GAP=3
  y_start: top-20 → top-29  (bleed was 13pt — worst of all scripts)
  height:  20+rows*(B+G) → 29+rows*(B+G)
  Number font: 9pt → 8pt, vertical centre adjusted

Stormbringer v3 (build_character_pdf_v3.py):
  BOX kept at 15, y_start: top-18 → top-29  (bleed was 9pt)
  height: 18+rows*(B+G) → 29+rows*(B+G)
  Portrait paths updated: /mnt/user-data/uploads → repo art directory
  IMG_BASE cleared (paths now fully absolute)

Ringworld (apps/ringworld-character-sheets/build.py):
  CLEAN — uses fixed mm-sized anatomy diagram boxes, not flowable tracks.
  Labels sit inside each box at fixed mm offsets. No bleed risk.

All PDFs rebuild successfully at correct page counts:
  BRP Day One: 14 pages, Night Crawler: 13 pages, Stormbringer: 13 pages
This commit is contained in:
timevans666
2026-05-19 10:24:10 +00:00
parent 6ab87271af
commit 27c0ace199
2 changed files with 13 additions and 13 deletions
@@ -346,7 +346,7 @@ class SectionBanner(Flowable):
class HPTrack(Flowable):
"""Clean HP tracker: 20pt boxes, every-5th-shaded, NO milestone text, numbers at 9pt."""
BOX=20; GAP=4
BOX=14; GAP=3
def __init__(self, max_hp, width, t, label="HIT POINTS", fill=None, fill5=None, border=None, num=None):
super().__init__()
self.max_hp=max_hp; self.width=width; self._t=t
@@ -361,7 +361,7 @@ class HPTrack(Flowable):
bpr=int(width/(B+G)); self.bpr=max(bpr,1)
rows=(max_hp+self.bpr-1)//self.bpr
# Height: 20pt label area + rows*(box+gap) - last gap
self.height=20+rows*(B+G)
self.height=29+rows*(B+G)
def wrap(self,a,b): return (self.width,self.height)
def draw(self):
c=self.canv; B,G=self.BOX,self.GAP
@@ -374,7 +374,7 @@ class HPTrack(Flowable):
c.setStrokeColor(self._border); c.setLineWidth(0.4)
c.line(0,top-16,w,top-16)
y_start=top-20 # top of first row of boxes
y_start=top-29 # clear 2pt above label baseline (top-13)
for i in range(max_hp):
row=i//bpr; col=i%bpr
@@ -395,7 +395,7 @@ class HPTrack(Flowable):
c.rect(x,y,B,B,fill=1,stroke=1)
# Number — 9pt, centered
c.setFillColor(self._num); c.setFont('Helvetica-Bold',9)
c.drawCentredString(x+B/2, y+(B-7)/2, str(hp_val))
c.drawCentredString(x+B/2, y+(B-6)/2, str(hp_val))
class PortraitPlaceholder(Flowable):
"""Reserved portrait space — to be replaced with Midjourney art."""
@@ -34,14 +34,14 @@ PAGE_W, PAGE_H = A4
MARGIN = 17 * mm
# ── IMAGE PATHS ─────────────────────────────────────────────────────────────
IMG_BASE = '/mnt/user-data/uploads/'
IMG_BASE = ''
PORTRAITS = {
'Davin Kell': IMG_BASE + 'slaguru6666_Portrait_of_a_mercenary_warrior_Ilmioran_mid-thir_8f2350d5-3c94-427c-8a15-907aca7f9bef_0_Medium.jpeg',
'Ysolde of Vilmir': IMG_BASE + 'slaguru6666_Portrait_of_a_young_Vilmirian_woman_scholar-sorce_9eafe360-7808-4b56-80ae-0b1acd8c1e21_1_Medium.jpeg',
'Cray': IMG_BASE + 'slaguru6666_Portrait_of_a_young_woman_thief_from_Nadsokor_the_07aa47cd-ad56-4fe6-b277-9c81d5861791_0_Medium.jpeg',
'Sarath the Twice-Turned': IMG_BASE + 'slaguru6666_Portrait_of_a_heavyset_man_in_his_forties_Pan_Tan_d937185c-9eed-4bbc-9b69-8cb28eb5882c_2_Medium.jpeg',
'Captain Brenn': IMG_BASE + 'slaguru6666_Portrait_of_a_Tarkeshite_sea_captain_late_thirtie_a95be513-e159-4beb-ba5e-522c1024ad50_2_Medium.jpeg',
'Lian': IMG_BASE + 'slaguru6666_Portrait_of_a_young_Ilmioran_devotee_of_Law_mid-t_098976e0-2bbc-47cd-8dd0-2552786a2bac_1_Medium.jpeg',
'Davin Kell': IMG_BASE + '/home/claude/ChaosiumCon26/scenarios/art/stormbringer/sb-pc01-davin-kell.jpeg',
'Ysolde of Vilmir': IMG_BASE + '/home/claude/ChaosiumCon26/scenarios/art/stormbringer/sb-pc02-ysolde-vilmir.jpeg',
'Cray': IMG_BASE + '/home/claude/ChaosiumCon26/scenarios/art/stormbringer/sb-pc03-cray.jpeg',
'Sarath the Twice-Turned': IMG_BASE + '/home/claude/ChaosiumCon26/scenarios/art/stormbringer/sb-pc04-sarath.jpeg',
'Captain Brenn': IMG_BASE + '/home/claude/ChaosiumCon26/scenarios/art/stormbringer/sb-pc05-captain-brenn.jpeg',
'Lian': IMG_BASE + '/home/claude/ChaosiumCon26/scenarios/art/stormbringer/sb-pc06-lian.jpeg',
}
IMG_RATIO = 640 / 427 # height/width
@@ -180,7 +180,7 @@ class HitPointTrack(Flowable):
bpr = int(width / (self.BOX + self.GAP))
self.bpr = bpr
self.rows = (max_hp + bpr - 1) // bpr
self.height = 18 + self.rows * (self.BOX + 10 + self.GAP)
self.height = 29 + self.rows * (self.BOX + 10 + self.GAP)
def wrap(self, aW, aH): return (self.width, self.height)
@@ -197,7 +197,7 @@ class HitPointTrack(Flowable):
c.setStrokeColor(RULE_COLOR); c.setLineWidth(0.4)
c.line(0,top-15,w,top-15)
y_start=top-18
y_start=top-29 # clear 2pt above label baseline (top-12)
for i in range(max_hp):
row=i//bpr; col=i%bpr
items_in_row=min(bpr,max_hp-row*bpr)