@@ -199,33 +199,82 @@ def _sanitize(text):
199199
200200
201201MIN_COL_W = 20 # pt — minimum usable column width
202+ MAX_COL_W = 100 # pt — maximum column width (≈ 20 chars at 8 pt Helvetica)
202203
203204
204205def _col_widths (columns , available_w ):
205- """Return a list of point widths proportional to each column's <size>."""
206+ """
207+ Return column widths fitting *available_w*, clamped to [MIN_COL_W, MAX_COL_W].
208+ Excess/deficit from clamped columns is redistributed iteratively among the rest.
209+ """
206210 sizes = [c ["size" ] for c in columns ]
207211 total = sum (sizes )
208212 if total > 0 and all (s > 0 for s in sizes ):
209- return [available_w * s / total for s in sizes ]
213+ widths = [0.0 ] * len (sizes )
214+ fixed = set ()
215+ for _ in range (len (sizes )):
216+ free = [i for i in range (len (sizes )) if i not in fixed ]
217+ if not free :
218+ break
219+ fixed_sum = sum (widths [i ] for i in fixed )
220+ free_avail = available_w - fixed_sum
221+ free_total = sum (sizes [i ] for i in free )
222+ if free_total <= 0 :
223+ break
224+ did_clamp = False
225+ for i in free :
226+ w = free_avail * sizes [i ] / free_total
227+ if w < MIN_COL_W :
228+ widths [i ] = MIN_COL_W
229+ fixed .add (i )
230+ did_clamp = True
231+ elif w > MAX_COL_W :
232+ widths [i ] = MAX_COL_W
233+ fixed .add (i )
234+ did_clamp = True
235+ else :
236+ widths [i ] = w
237+ if not did_clamp :
238+ break
239+ return widths
210240 n = len (columns ) or 1
211241 return [available_w / n ] * len (columns )
212242
213243
214244def _required_avail_w (columns ):
215245 """
216- Return the minimum available_w so every proportional column >= MIN_COL_W.
217- Used to expand the page rather than squish columns.
246+ Return the minimum available_w so every column fits within [MIN_COL_W, MAX_COL_W].
247+
248+ Each column is allocated ``clamp(size * MAX_COL_W / max_size)`` — proportional
249+ to the largest column at MAX_COL_W — with a floor of MIN_COL_W. Summing these
250+ gives the page width where the iterative clamp in _col_widths converges cleanly.
218251 """
219252 sizes = [c ["size" ] for c in columns ]
220253 pos = [s for s in sizes if s > 0 ]
221254 if not pos :
222255 return 0
223- return MIN_COL_W * sum (sizes ) / min (pos )
256+ max_s = max (pos )
257+ return sum (
258+ max (MIN_COL_W , min (MAX_COL_W , s * MAX_COL_W / max_s ))
259+ for s in sizes if s > 0
260+ )
261+
262+
263+ def _split_camel (text ):
264+ """Insert spaces at camelCase boundaries: 'WindowRooms' → 'Window Rooms'."""
265+ import re
266+ return re .sub (r'([a-z])([A-Z])' , r'\1 \2' , text )
224267
225268
226269def _wrap_text (text , max_width , canv , font , size ):
227- """Break *text* into lines that fit within *max_width* points."""
228- words = text .split ()
270+ """
271+ Break *text* into lines that fit within *max_width* points.
272+
273+ CamelCase tokens are split first so e.g. 'WindowRooms' wraps as
274+ two words. Words wider than *max_width* are kept whole — callers
275+ that need overflow clipping should widen their clip rect accordingly.
276+ """
277+ words = _split_camel (text ).split ()
229278 lines , line = [], ""
230279 for word in words :
231280 candidate = (line + " " + word ).strip ()
@@ -293,18 +342,34 @@ def _draw_branding_header(canv, pw, y):
293342 return y
294343
295344
345+ def _fit_col_widths (columns , cws , canv ):
346+ """
347+ Return column widths expanded so each column is at least as wide as its
348+ wrapped header text: max(field_width, header_text_width).
349+ """
350+ result = []
351+ for col_info , cw in zip (columns , cws ):
352+ lines = _wrap_text (col_info ["attribute" ], cw - 2 , canv , "Helvetica-Bold" , HEADER_SIZE )
353+ display = lines [- HEADER_LINES :]
354+ text_w = max (
355+ canv .stringWidth (ln , "Helvetica-Bold" , HEADER_SIZE ) for ln in display
356+ )
357+ result .append (max (cw , text_w + 4 ))
358+ return result
359+
360+
296361def _draw_column_headers (canv , columns , col_widths , x0 , y_top ):
297362 """
298363 Draw column headers above a data grid, bottom-aligned within each column.
299- Each column is clipped to its own width so text never bleeds into neighbours.
364+ Assumes col_widths have already been expanded via _fit_col_widths so no
365+ text ever exceeds its column width.
300366 Returns the y coordinate of the bottom of the header block.
301367 """
302368 header_h = HEADER_LINES * (HEADER_SIZE + 1 ) + 2
303369 for col_info , cw in zip (columns , col_widths ):
304370 lines = _wrap_text (col_info ["attribute" ], cw - 2 , canv , "Helvetica-Bold" , HEADER_SIZE )
305371 display = lines [- HEADER_LINES :]
306372
307- # Clip this column so long words cannot bleed into the next column.
308373 canv .saveState ()
309374 clip = canv .beginPath ()
310375 clip .rect (x0 , y_top - header_h , cw - 1 , header_h )
@@ -435,7 +500,9 @@ def generate_form_pdf(form, rows, output_path):
435500
436501 # ── Heading fields (form metadata) ─────────────────────────────────────
437502 if form ["headings" ]:
438- heading_w = avail_w / HEADING_PER_ROW
503+ # Use the base (non-expanded) page width so heading fields stay
504+ # a reasonable size regardless of how wide the data columns require.
505+ heading_w = base_avail / HEADING_PER_ROW
439506 col = 0
440507 row_top = y
441508 for i , heading in enumerate (form ["headings" ]):
@@ -445,8 +512,8 @@ def generate_form_pdf(form, rows, output_path):
445512 # Label
446513 c .setFont ("Helvetica" , LABEL_SIZE )
447514 c .drawString (hx , row_top - LABEL_SIZE , label )
448- # Field
449- field_w = heading_w - lw - 2
515+ # Field — cap at MAX_COL_W so headings stay compact
516+ field_w = min ( heading_w - lw - 2 , MAX_COL_W )
450517 if field_w >= 20 :
451518 c .acroForm .textfield (
452519 name = f"heading_{ i } " ,
@@ -489,7 +556,7 @@ def generate_form_pdf(form, rows, output_path):
489556 c .drawString (MARGIN , y - (LABEL_SIZE + 1 ), title )
490557 y -= LABEL_SIZE + 1 + 3
491558
492- cws = _col_widths (columns , avail_w )
559+ cws = _fit_col_widths ( columns , _col_widths (columns , avail_w ), c )
493560
494561 # ── family: Groom + Bride split ─────────────────────────────────
495562 if stype == "family" :
@@ -500,7 +567,7 @@ def generate_form_pdf(form, rows, output_path):
500567 side_keys = ["Groom" , "Bride" ]
501568
502569 for s_label , sx , sk in zip (side_labels , side_x , side_keys ):
503- side_cws = _col_widths (columns , half_w )
570+ side_cws = _fit_col_widths ( columns , _col_widths (columns , half_w ), c )
504571 # Side sub-title
505572 c .setFont ("Helvetica-Bold" , LABEL_SIZE )
506573 c .drawString (sx , y - LABEL_SIZE , s_label or sk )
@@ -510,7 +577,7 @@ def generate_form_pdf(form, rows, output_path):
510577
511578 # One data row per side
512579 for sx , sk in zip (side_x , side_keys ):
513- side_cws = _col_widths (columns , half_w )
580+ side_cws = _fit_col_widths ( columns , _col_widths (columns , half_w ), c )
514581 _add_row_fields (c , columns , side_cws , sx , y , f"{ role } _{ sk } " , "1" )
515582 y -= ROW_HEIGHT
516583
0 commit comments