Skip to content

Commit d7ef936

Browse files
mrdavearmsclaude
andcommitted
fix: resolve 11 bugs from full codebase audit
- fix: `not self.df` crash in _auto_map_fields (ValueError on every data load) - fix: template load without re-analysis silently producing wrong output - fix: combed field crash when field.length is None - fix: date serial strings ("45000") not converted to DD/MM/YYYY - fix: startup crash on locked-down school networks (fallback makedirs) - fix: Excel file handle leak locking .xlsx on Windows - fix: AppSettings encoding mismatch (non-ASCII school names corrupted) - fix: TemplateConfig.from_file() unhandled OSError - fix: orphaned .tmp files on os.replace() failure (network drives) - fix: stale preview cache across different PDFs (added hash key) - fix: Windows download URL 404 in GitHub Releases (EXE rename) - fix: clear_cache() filter updated for new cache filename format - docs: update release notes for v2.10 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a24a004 commit d7ef936

6 files changed

Lines changed: 125 additions & 40 deletions

File tree

.github/workflows/release.yml

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,20 +45,22 @@ jobs:
4545
- name: Build .exe
4646
run: python -m PyInstaller BulkPDFGenerator.spec --clean --noconfirm
4747

48-
- name: Verify build output
48+
- name: Verify build output and rename
4949
run: |
5050
if (!(Test-Path "dist/Bulk PDF Generator.exe")) {
5151
Write-Error "Build failed — .exe not found"
5252
exit 1
5353
}
54-
$size = (Get-Item "dist/Bulk PDF Generator.exe").Length / 1MB
55-
Write-Host "Built: Bulk PDF Generator.exe ($([math]::Round($size, 1)) MB)"
54+
# Rename to dotted form so GitHub Release download URLs match
55+
Rename-Item "dist/Bulk PDF Generator.exe" "Bulk.PDF.Generator.exe"
56+
$size = (Get-Item "dist/Bulk.PDF.Generator.exe").Length / 1MB
57+
Write-Host "Built: Bulk.PDF.Generator.exe ($([math]::Round($size, 1)) MB)"
5658
shell: pwsh
5759

5860
- uses: actions/upload-artifact@v4
5961
with:
6062
name: windows-exe
61-
path: dist/Bulk PDF Generator.exe
63+
path: dist/Bulk.PDF.Generator.exe
6264
retention-days: 1
6365

6466
# ── macOS .app build ────────────────────────────────────────
@@ -239,5 +241,5 @@ jobs:
239241
240242
</details>
241243
files: |
242-
artifacts/Bulk PDF Generator.exe
244+
artifacts/Bulk.PDF.Generator.exe
243245
artifacts/Bulk.PDF.Generator.macOS.dmg

RELEASE_NOTES.md

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,28 @@
1111
comment and start fresh for the next version.
1212
-->
1313

14-
## What's new in v2.9
14+
## What's new in v2.10
1515

16-
### Major performance improvements — especially on Mac
16+
### Reliability and stability fixes
1717

18-
- **Fixed: app freezing and lagging on macOS** — The app was nearly unusable on Mac, with tabs not responding, field selection freezing the window, and general sluggishness throughout. This release completely fixes those issues. Field previews now render in the background instead of freezing the window, and all bulk operations (Select All, loading data, switching tabs) are dramatically faster.
18+
This release fixes a number of issues discovered during a full codebase audit. Most of these affect edge cases that teachers on school networks are most likely to encounter.
1919

20-
- **Smoother field preview**Clicking through fields in the Analyse tab now feels instant. A fast preview appears immediately, then a high-quality version follows a moment later — no more waiting for each click.
20+
- **Fixed: "Failed to load data" error after loading a spreadsheet**A bug caused the app to show a confusing error message every time you loaded an Excel file after analysing a template. Auto-mapping of fields now works correctly.
2121

22-
- **Faster data loading**Loading a spreadsheet with hundreds of rows no longer causes a visible pause. The "Select All" operation and field mapping panel both update much more efficiently.
22+
- **Fixed: loading a saved template could produce wrong output**If you loaded a saved template on a fresh launch without re-analysing, the app would silently skip your saved field mappings, data types, and combed field settings. Templates now restore all settings automatically.
2323

24-
- **Smoother scrolling and hover effects**Mouse hover highlighting and scrolling in all lists and dialogs is now throttled to prevent unnecessary work, making the whole app feel more responsive.
24+
- **Fixed: date fields showing numbers instead of dates**Date columns stored as serial numbers in Excel (e.g. "45000" instead of "17/03/2023") were being written as-is into the PDF. They are now correctly converted to DD/MM/YYYY format.
2525

26-
- **Dialogs no longer flicker** — The School Setup, Template Name, and Field Type dialogs now appear cleanly without the brief flicker that was visible on macOS.
26+
- **Fixed: app crashing on startup on some school networks** — On machines where both the Documents folder and the local app data folder are unavailable (common with GPO-locked school networks), the app would crash before any window appeared. It now falls back gracefully.
27+
28+
- **Fixed: Excel file stays locked after loading** — On Windows, the Excel file remained locked after you loaded it, preventing you from editing or re-saving it until you restarted the app. The file is now released immediately after loading.
29+
30+
- **Fixed: combed fields crashing on certain PDFs** — Combed fields where the PDF didn't report a character limit could crash the app. These are now handled safely.
31+
32+
- **Fixed: settings corruption with non-ASCII school names** — If your school name contained accented or special characters, it could become garbled after restarting the app on Windows. Settings are now always read and written as UTF-8.
33+
34+
- **Fixed: preview cache showing wrong PDF** — If you switched between two PDF templates with the same number of pages, the preview could show pages from the previous template. Each PDF now has its own cache.
35+
36+
- **Fixed: Windows download link on GitHub Releases** — The download link for the Windows .exe in previous releases could return a 404 error. This is now fixed.
37+
38+
- **Improved: template file and settings resilience** — Saved templates and settings files are now more resilient to file system errors on network drives. Temporary files are cleaned up properly if a save is interrupted.

combed_filler.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,17 +54,20 @@ def fill_field(
5454
# Clean and prepare text
5555
text = str(text_value).strip()
5656

57+
# Use combed_fields count as the canonical capacity
58+
capacity = len(field.combed_fields) if field.combed_fields else (field.length or 0)
59+
5760
# Truncate if too long
58-
if len(text) > field.length:
59-
text = text[:field.length]
61+
if capacity and len(text) > capacity:
62+
text = text[:capacity]
6063

6164
# Determine alignment
62-
if self.align == 'right' and len(text) < field.length:
65+
if self.align == 'right' and capacity and len(text) < capacity:
6366
# Right-align by padding left
64-
text = text.rjust(field.length)
65-
elif self.padding and len(text) < field.length:
67+
text = text.rjust(capacity)
68+
elif self.padding and capacity and len(text) < capacity:
6669
# Pad with spaces to fill all boxes
67-
text = text.ljust(field.length)
70+
text = text.ljust(capacity)
6871

6972
# Create field mapping
7073
field_values = {}
@@ -76,7 +79,7 @@ def fill_field(
7679
field_values[field_name] = char
7780

7881
# Fill remaining boxes with empty string (or spaces if padding)
79-
for idx in range(len(text), field.length):
82+
for idx in range(len(text), capacity):
8083
if idx < len(field.combed_fields):
8184
field_name = field.combed_fields[idx]
8285
field_values[field_name] = ' ' if self.padding else ''
@@ -157,10 +160,11 @@ def validate_overflow(
157160
# Not a combed field - no overflow possible
158161
return result
159162

160-
if len(text) > field.length:
163+
capacity = len(field.combed_fields) if field.combed_fields else (field.length or 0)
164+
if capacity and len(text) > capacity:
161165
result['is_valid'] = False
162166
result['will_truncate'] = True
163-
result['truncated_text'] = text[:field.length]
167+
result['truncated_text'] = text[:capacity]
164168

165169
return result
166170

models.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,8 @@ def from_file(cls, filepath: str) -> 'TemplateConfig':
118118
raise ValueError(f"Template file is not valid JSON: {filepath}") from e
119119
except (TypeError, KeyError) as e:
120120
raise ValueError(f"Template file has missing or invalid fields: {filepath}") from e
121+
except OSError as e:
122+
raise ValueError(f"Cannot read template file: {filepath}") from e
121123

122124
def save_to_file(self, filepath: str):
123125
"""Save to JSON file atomically to prevent corruption on crash."""
@@ -128,7 +130,14 @@ def save_to_file(self, filepath: str):
128130
encoding='utf-8') as tmp:
129131
tmp.write(self.to_json())
130132
tmp_path = tmp.name
131-
os.replace(tmp_path, filepath)
133+
try:
134+
os.replace(tmp_path, filepath)
135+
except OSError:
136+
try:
137+
os.unlink(tmp_path)
138+
except OSError:
139+
pass
140+
raise
132141

133142

134143
@dataclass
@@ -171,7 +180,7 @@ def from_json(cls, json_str: str) -> 'AppSettings':
171180
def from_file(cls, filepath: str) -> 'AppSettings':
172181
"""Load from JSON file."""
173182
try:
174-
with open(filepath, 'r') as f:
183+
with open(filepath, 'r', encoding='utf-8') as f:
175184
return cls.from_json(f.read())
176185
except (FileNotFoundError, PermissionError, OSError,
177186
json.JSONDecodeError, TypeError, KeyError):
@@ -186,7 +195,14 @@ def save_to_file(self, filepath: str):
186195
encoding='utf-8') as tmp:
187196
tmp.write(self.to_json())
188197
tmp_path = tmp.name
189-
os.replace(tmp_path, filepath)
198+
try:
199+
os.replace(tmp_path, filepath)
200+
except OSError:
201+
try:
202+
os.unlink(tmp_path)
203+
except OSError:
204+
pass
205+
raise
190206

191207
@classmethod
192208
def get_defaults(cls) -> 'AppSettings':

pdf_generator.py

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,13 @@ def _resolve_data_dir() -> str:
160160
os.environ.get('LOCALAPPDATA', os.path.expanduser('~')),
161161
'BulkPDFGenerator'
162162
)
163-
os.makedirs(fallback, exist_ok=True)
164-
return fallback
163+
try:
164+
os.makedirs(fallback, exist_ok=True)
165+
return fallback
166+
except OSError:
167+
# Last resort: use a temp directory so the app can still launch
168+
import tempfile
169+
return tempfile.mkdtemp(prefix='BulkPDFGenerator_')
165170

166171

167172
# Import our new modules
@@ -1515,6 +1520,36 @@ def load_template_config(self, filepath: str):
15151520
self.pdf_template_path.set(self.current_template.pdf_path)
15161521
self.template_name_var.set(self.current_template.template_name)
15171522

1523+
# If analyzed_fields is empty, silently re-analyze the PDF so that
1524+
# data types, mappings, and combed logic are all functional.
1525+
pdf_path = self.current_template.pdf_path
1526+
if not self.analyzed_fields and pdf_path and os.path.exists(pdf_path):
1527+
with PDFAnalyzer(pdf_path) as analyzer:
1528+
self.analyzed_fields = analyzer.analyze_fields()
1529+
1530+
# Restore field type overrides from the template
1531+
if self.current_template.field_type_overrides:
1532+
for field in self.analyzed_fields:
1533+
override = self.current_template.field_type_overrides.get(field.field_name)
1534+
if override:
1535+
field.field_type = override.get('field_type', field.field_type)
1536+
if override.get('length') is not None:
1537+
field.length = override['length']
1538+
field.is_combed = (field.field_type == 'Text-Combed')
1539+
1540+
# Initialize preview generator for the loaded PDF
1541+
self._close_preview_generator()
1542+
cache_dir = os.path.join(self.settings.templates_directory, '.preview_cache')
1543+
self.preview_generator = VisualPreviewGenerator(pdf_path, cache_dir)
1544+
self.preview_generator.__enter__()
1545+
1546+
def _on_preview_complete(photo):
1547+
self.preview_image = photo
1548+
self._preview_renderer = PreviewRenderer(
1549+
self.preview_generator, self.root,
1550+
self.preview_canvas, _on_preview_complete,
1551+
)
1552+
15181553
# Restore saved data types to analyzed fields if they exist
15191554
saved_types = self.current_template.field_data_types or {}
15201555
if saved_types and self.analyzed_fields:
@@ -1539,6 +1574,9 @@ def load_template_config(self, filepath: str):
15391574
if field.field_name in saved_critical:
15401575
field.is_critical = True
15411576

1577+
# Enable Tab 2 and refresh mappings
1578+
if self.analyzed_fields:
1579+
self.notebook.tab(2, state='normal')
15421580
self._refresh_tab2_mappings()
15431581

15441582
# Show template-specific banner message if mappings were restored
@@ -2531,7 +2569,7 @@ def _on_mapping_changed(self, field_name: str, combo: ttk.Combobox, status_lbl:
25312569

25322570
def _auto_map_fields(self):
25332571
"""Apply smart-guess mappings to all fields, overwriting existing mappings."""
2534-
if not self.df or not self.analyzed_fields:
2572+
if self.df is None or not self.analyzed_fields:
25352573
return
25362574
column_lower = {col.lower(): col for col in self.df.columns}
25372575
for field in self.analyzed_fields:
@@ -2902,15 +2940,15 @@ def load_data_tab3(self):
29022940
except UnicodeDecodeError:
29032941
self.df = pd.read_csv(excel_path, encoding='latin-1')
29042942
else:
2905-
xl = pd.ExcelFile(excel_path)
2906-
sheet_names = xl.sheet_names
2907-
if len(sheet_names) == 1:
2908-
chosen_sheet = sheet_names[0]
2909-
else:
2910-
chosen_sheet = self._pick_excel_sheet(sheet_names)
2911-
if chosen_sheet is None:
2912-
return # User cancelled the dialog
2913-
self.df = xl.parse(chosen_sheet, dtype=str)
2943+
with pd.ExcelFile(excel_path) as xl:
2944+
sheet_names = xl.sheet_names
2945+
if len(sheet_names) == 1:
2946+
chosen_sheet = sheet_names[0]
2947+
else:
2948+
chosen_sheet = self._pick_excel_sheet(sheet_names)
2949+
if chosen_sheet is None:
2950+
return # User cancelled the dialog
2951+
self.df = xl.parse(chosen_sheet, dtype=str)
29142952

29152953
# Clean column names (strip whitespace, lowercase for matching)
29162954
self.df.columns = [str(col).strip() for col in self.df.columns]
@@ -3399,11 +3437,22 @@ def format_value_tab3(val, data_type: str = "text"):
33993437
# Date type: handle pandas-stringified datetimes e.g. "2024-05-01 00:00:00"
34003438
# (occurs when dtype=str is used on read_excel — Timestamps become strings)
34013439
if data_type == "date" and isinstance(val, str):
3402-
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d'):
3440+
for fmt in ('%Y-%m-%d %H:%M:%S.%f', '%Y-%m-%d %H:%M:%S', '%Y-%m-%d'):
34033441
try:
34043442
return datetime.strptime(val.strip(), fmt).strftime('%d/%m/%Y')
34053443
except ValueError:
34063444
pass
3445+
# Handle numeric serial strings produced by dtype=str
3446+
# (e.g. "45000" or "45000.0" instead of a formatted date)
3447+
try:
3448+
serial = int(float(val.strip()))
3449+
if 1 <= serial <= 2958465:
3450+
from datetime import timedelta
3451+
excel_epoch = datetime(1899, 12, 30)
3452+
date_obj = excel_epoch + timedelta(days=serial)
3453+
return date_obj.strftime('%d/%m/%Y')
3454+
except (ValueError, TypeError):
3455+
pass
34073456

34083457
# Date type: convert Excel serial numbers to DD/MM/YYYY
34093458
if data_type == "date" and isinstance(val, (int, float)):

visual_preview.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
Handles PDF page rendering and field highlighting for visual preview.
55
"""
66

7+
import hashlib
78
import os
89
import tempfile
910
from collections import OrderedDict
@@ -158,8 +159,9 @@ def _get_page_image(self, page_num: int, dpi: int) -> Image.Image:
158159
self._cached_pages.move_to_end(cache_key)
159160
return self._cached_pages[cache_key]
160161

161-
# Check disk cache
162-
cache_filename = f"page_{page_num}_dpi_{dpi}.png"
162+
# Check disk cache (include PDF identity to prevent stale cross-PDF hits)
163+
pdf_hash = hashlib.md5(self.pdf_path.encode()).hexdigest()[:8]
164+
cache_filename = f"{pdf_hash}_page_{page_num}_dpi_{dpi}.png"
163165
cache_path = os.path.join(self.cache_dir, cache_filename)
164166

165167
if os.path.exists(cache_path):
@@ -192,7 +194,7 @@ def clear_cache(self):
192194
# Clear disk cache (only app-generated files, with error handling)
193195
if os.path.exists(self.cache_dir):
194196
for file in os.listdir(self.cache_dir):
195-
if file.startswith('page_') and file.endswith('.png'):
197+
if file.endswith('.png') and '_page_' in file:
196198
try:
197199
os.remove(os.path.join(self.cache_dir, file))
198200
except OSError:

0 commit comments

Comments
 (0)