Skip to content

Commit 943479d

Browse files
Sync addons-source@maintenance/gramps61 with upstream/gramps-project (2026-05-25)
2 parents fe9a87d + 479b9dd commit 943479d

12 files changed

Lines changed: 550 additions & 52 deletions

GrampsWebSync/grampswebsync.gpr.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
id="gramps_web_sync",
2929
name=_("Gramps Web Sync"),
3030
description=_("Synchronizes a local database with a Gramps Web instance."),
31-
version = '1.3.11',
31+
version = '1.3.12',
3232
gramps_target_version="6.1",
3333
status=STABLE,
3434
fname="grampswebsync.py",

GrampsWebSync/grampswebsync.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ def __init__(self, dbstate, user, options_class, name, *args, **kwargs) -> None:
180180

181181
self.db1 = dbstate.db
182182
self.db2 = None
183+
self._closing = False
183184
self._download_timestamp = 0
184185
self._changes: Actions | None = None
185186
self._sync: WebApiSyncDiffHandler | None = None
@@ -213,6 +214,14 @@ def build_menu_names(self, obj): # type: ignore
213214
def do_close(self, assistant):
214215
"""Close the assistant."""
215216
LOG.debug("Closing Gramps Web Sync addon.")
217+
self._closing = True
218+
if self.db2 is not None:
219+
LOG.debug("Closing in-memory remote database.")
220+
self.db2.close()
221+
self.db2 = None
222+
# Clear the diff handler which holds references to both db1 and db2
223+
self._sync = None
224+
self._changes = None
216225
position = self.window.get_position() # crock
217226
self.assistant.hide()
218227
self.window.move(position[0], position[1])
@@ -500,6 +509,8 @@ def async_compare_dbs(self):
500509

501510
def get_diff_actions(self) -> None:
502511
"""Download the remote data, import it and compare it to local."""
512+
if self._closing:
513+
return
503514
LOG.info("Downloading Gramps XML file.")
504515
path = self.handle_server_errors(self.api.download_xml)
505516
if path is None:
@@ -545,6 +556,8 @@ def async_transfer_media(self):
545556

546557
def _async_transfer_media(self):
547558
"""Upload/download media files."""
559+
if self._closing:
560+
return
548561
self.handle_server_errors(self.download_files)
549562
if self.conclusion.error:
550563
return
@@ -665,6 +678,8 @@ def _async_commit_actions_to_remote(
665678
self, payload: dict[str, "Any"], force: bool
666679
) -> None:
667680
"""Upload/download media files."""
681+
if self._closing:
682+
return
668683
LOG.debug("Committing changes to remote database.")
669684
self.handle_server_errors(
670685
self.api.commit,

LinesOfDescendency/lines-of-descendency.gpr.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
"Prints out all descendency lines "
3434
"from a given ancestor to a given descendent in text."
3535
),
36-
version = '1.1.41',
36+
version = '1.1.42',
3737
gramps_target_version="6.1",
3838
status=STABLE, # not yet tested with python 3
3939
fname="lines-of-descendency.py",

LinesOfDescendency/lines-of-descendency.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,14 @@ def write_path(self, path):
126126
mother if mother != handle \
127127
else family.get_father_handle()
128128
handle = next_handle
129-
spouse = self.database.get_person_from_handle(spouse_handle)
129+
# Bug 12913: a family may have only one parent defined,
130+
# in which case spouse_handle is None and
131+
# get_person_from_handle raises HandleError. Guard the
132+
# lookup so the existing "N.N." fallback still applies.
133+
if spouse_handle:
134+
spouse = self.database.get_person_from_handle(spouse_handle)
135+
else:
136+
spouse = None
130137
if spouse:
131138
spouse_name = _nd.display(spouse)
132139
else:

LinesOfDescendency/tests/test_linesofdescendency_guards.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,5 +127,100 @@ def test_both_present_does_not_raise(self):
127127
self.assertIsNotNone(report.ancestor)
128128

129129

130+
class TestWritePathMissingSpouse(unittest.TestCase):
131+
"""Bug 12913: ``write_path`` raised ``HandleError: Handle is None``
132+
when a family in the descent chain had only one parent defined
133+
(so ``spouse_handle`` came back as ``None``) and the addon called
134+
``get_person_from_handle(None)`` directly. The guard added in
135+
this PR catches the ``None`` before the lookup and falls through
136+
to the existing ``'N.N.'`` spouse-name fallback that was already
137+
in place for the "spouse object missing" case."""
138+
139+
@classmethod
140+
def setUpClass(cls):
141+
try:
142+
cls.impl = _load_impl()
143+
except ImportError as err:
144+
raise unittest.SkipTest("Gramps not importable: %s" % err)
145+
cls.LinesOfDescendency = cls.impl.LinesOfDescendency
146+
147+
@staticmethod
148+
def _report_instance(database, doc):
149+
"""Build a ``LinesOfDescendency`` instance via ``__new__`` so
150+
``Report.__init__`` (which we don't need) doesn't run, and
151+
wire up the minimum attributes ``write_path`` touches."""
152+
report = TestWritePathMissingSpouse.LinesOfDescendency.__new__(
153+
TestWritePathMissingSpouse.LinesOfDescendency
154+
)
155+
report.database = database
156+
report.doc = doc
157+
report.line = 1
158+
return report
159+
160+
@staticmethod
161+
def _person(handle, gramps_id, parents_family_handle):
162+
"""Mock person with the minimal API ``write_path`` touches."""
163+
person = mock.MagicMock()
164+
person.get_handle.return_value = handle
165+
person.get_gramps_id.return_value = gramps_id
166+
person.get_main_parents_family_handle.return_value = (
167+
parents_family_handle
168+
)
169+
return person
170+
171+
@staticmethod
172+
def _family(mother_handle, father_handle, relationship):
173+
family = mock.MagicMock()
174+
family.get_mother_handle.return_value = mother_handle
175+
family.get_father_handle.return_value = father_handle
176+
family.get_relationship.return_value = relationship
177+
return family
178+
179+
def test_single_parent_family_does_not_raise(self):
180+
"""The reporter's case: a family in the chain has only one
181+
parent defined. Pre-fix, ``get_person_from_handle(None)``
182+
raised ``HandleError: Handle is None`` and torpedoed the
183+
whole report."""
184+
# Chain: ancestor (handle "A") -> child (handle "C")
185+
# Family F0037 has father "A" and mother = None ("Reeves, Maria"
186+
# removed per reporter's repro). When traversing from ancestor
187+
# to child, spouse_handle resolves to None.
188+
ancestor = self._person("A", "I0001", parents_family_handle=None)
189+
child = self._person("C", "I0055", parents_family_handle="F0037")
190+
# Family with only the father, no mother:
191+
family = self._family(
192+
mother_handle=None,
193+
father_handle="A",
194+
relationship=self.impl.FamilyRelType.MARRIED,
195+
)
196+
197+
database = mock.MagicMock()
198+
database.get_person_from_handle.side_effect = lambda h: {
199+
"A": ancestor,
200+
"C": child,
201+
}[h]
202+
database.get_family_from_handle.return_value = family
203+
204+
doc = mock.MagicMock()
205+
report = self._report_instance(database, doc)
206+
207+
# Pre-fix this would raise HandleError; post-fix it must
208+
# complete and never call get_person_from_handle with None.
209+
report.write_path(["A", "C"])
210+
211+
# Verify get_person_from_handle was NEVER called with None
212+
# -- the whole point of the guard.
213+
for call in database.get_person_from_handle.call_args_list:
214+
self.assertIsNotNone(call.args[0])
215+
216+
# Verify the user-visible output still uses the existing
217+
# 'N.N.' fallback for the missing-spouse case.
218+
written_calls = [str(c) for c in doc.write_text.call_args_list]
219+
self.assertTrue(
220+
any("N.N." in text for text in written_calls),
221+
"Expected 'N.N.' fallback in rendered text; got %r" % written_calls,
222+
)
223+
224+
130225
if __name__ == "__main__":
131226
unittest.main()

PDFForms/PDFForms.gpr.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"Generate blank fillable PDF forms: census/event forms or "
2727
"Ahnentafel pedigree charts."
2828
),
29-
version = '1.0.1',
29+
version = '1.0.2',
3030
gramps_target_version="6.1",
3131
status=STABLE,
3232
fname="generatepdfform.py",
@@ -48,7 +48,7 @@
4848
"Import genealogy data from a PDF form. "
4949
"Send the PDF template to others to fill out and return."
5050
),
51-
version = '1.0.1',
51+
version = '1.0.2',
5252
gramps_target_version="6.1",
5353
status=STABLE,
5454
fname="importpdf.py",

PDFForms/generate_pdf.py

Lines changed: 82 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -199,33 +199,82 @@ def _sanitize(text):
199199

200200

201201
MIN_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

204205
def _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

214244
def _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

226269
def _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+
296361
def _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

Comments
 (0)