Delphi 13.1 Florence, TMS FNC Gantt Chart 1.3.3.7.
After any change to the task structure, the export resolves its rows through an internal index that is never rebuilt. Tasks are then skipped, repeated, or — in the worst case — every row shows the same task. The chart on screen stays correct and the row count stays correct, so the output looks plausible.
Reproduced in your own PDF Export demo
demos\PDF Export, unit UDPDFExportDemo.pas, one line added in SaveBtnClick right before the export:
TMSFNCGanttChart1.Project.InsertTask('1', 'Inserted', '');
PDFIO.Save('.\export.pdf');
The demo's tasks come from the form file, so ReadState calls CalculateTasks at start-up and the index is valid — export-without-insert.pdf is correct. Adding the line above produces export-with-insert.pdf:
| # | expected | exported |
|---|---|---|
| 1 | 1 Inserted | 1 Inserted |
| 2 | 2 Assessment | 2.1 Files Review |
| 3 | 2.1 Files Review | 3.2 Slice and Code |
| 4 | 2.2 Map Site | 4.3 Testing |
| 5 | 3 Design | 2 Assessment |
| 6 | 3.1 Mockups | 3.1 Mockups |
| 7 | 3.2 Slice and Code | 4.2 Scripts |
| 8 | 4 Development | 2 Assessment |
| 9 | 4.1 Page Templates | 3.1 Mockups |
| 10 | 4.2 Scripts | 4.2 Scripts |
| 11 | 4.3 Testing | 2 Assessment |
Map Site, Design, Development and Page Templates are missing entirely; Assessment appears three times. (The inserted task has no start date, which is why that PDF has eight pages — that is my one-liner, not the issue.)
In our own application, after an editing session that included deletions, all 64 rows showed the first task and no bars or dependency lines were drawn at all.
Cause
DrawExportTaskListresolves every row throughProject.GetTaskByCount(I)(VCL.TMSFNCGanttChart.pas, line 8969);DrawExportTimeLinedoes the same for the bars (9399) and the dependency lines (9282).GetTaskByCountwalks the project withGetNextTask, which works exclusively on the internal string listFTaskList(VCL.TMSFNCGanttClasses.pas, line 4686 ff.):
idx := FTaskList.IndexOfName(ATask.WBS);
if idx < FTaskList.Count - 1 then
Result := (FTaskList.Objects[idx + 1] as TTMSFNCGanttTask);
Once the WBS numbers have shifted, the lookup finds the wrong entry — the table above — or fails completely, in which case idx is -1 and the function returns Objects[0], the first task, for every call.
FTaskListis filled only inUpdateTaskList(line 5898), whose only call site in the whole package isCalculateTasks(line 4309).EndUpdatecallsCalculateTasksonly for work-time changes (line 4626 ff.):
if FModified or FTasksModified or FWorkTimeModified then
begin
if FWorkTimeModified then
CalculateTasks;
FTasksModified is evaluated in that very if, but never rebuilds the index. Nor does the insert itself: TTMSFNCGanttTasks.InsertTask calls inherited Insert and DoInternalTaskAdded, and ProjectTaskAdded only calls UpdateGanttListItems.
Deleted tasks additionally leave pointers to freed objects in FTaskList, which GetNextTask then casts with as TTMSFNCGanttTask. The run described above also produced three first-chance access violations (c0000005) in the print path; I have no call stack for those, but they never occurred with the workaround below in place.
The screen is unaffected because the task list draws from its own node tree, and GetTasksCount (line 4895) counts the real tree — which is why the row count stays right while the content is wrong.
Calling CalculateTasks first is not the answer
It does make the symptom go away, but:
- It contradicts your own documentation.
TTMSFNCGanttChart.GetNextTaskis documented as "Retrieves the next task in the WBS order" — WBS order is exactly what fails. And the XMLDoc ofTTMSFNCGanttProject.GetNextTaskin the shipped sources promises "The next task, or nil when no matching task exists", while the implementation returnsObjects[0].GetTaskByCountcarries the same promise. - No precondition is documented anywhere — not on those methods, and not in the
TTMSFNCGanttChartPrintIO/TTMSFNCGanttChartPDFIOdocumentation, which never mentionsCalculateTasks.UpdateTaskListappears in no manual at all; it isprotected.CalculateTasksis documented as "Recalculates task dates, durations, and dependencies" — nothing about a lookup index.EndUpdateis documented as "Forces to calculate and update all of the settings after setting multiple properties." - It has side effects on the data.
CalculateTasksrecalculates the times of every task and can move dates. A printout must not modify the plan — which is why our workaround usesUpdateTaskListinstead.
Not only the export
GetNextTask/GetPreviousTask(lines 5587 / 5681)SelectNextTask/SelectPreviousTask(6906 / 6918) — select the wrong taskGetTaskCountByTask(GanttClasses 4646) — returns a wrong index
GetNextVisibleTask / GetPreviousVisibleTask (5595 / 5688) are not affected; they walk FTimeline.Items.
Suggested fix
In TTMSFNCGanttProject.EndUpdate:
if FTasksModified then
UpdateTaskList;
Only the index — deliberately not the full CalculateTasks, so no task times are recalculated and no dates moved as a side effect.
At the very least, please make GetNextTask behave as documented and return nil when IndexOfName fails, instead of Objects[0]. That alone turns silently wrong output into something a caller can detect.
Workaround for anyone hitting this
type
TProjectHack = class(TTMSFNCGanttProject);
...
TProjectHack(GanttChart.Project).UpdateTaskList; // right before the export
Both frameworks
The FMX sources are affected identically: same GetNextTask, same single UpdateTaskList call site in CalculateTasks (line 4311), same EndUpdate, and the three GetTaskByCount call sites in FMX.TMSFNCGanttChart.pas sit at the same lines 8969, 9282 and 9399. The demo used above is the FMX one.
Line numbers refer to the sources shipped with 1.3.3.7.
export-with-insert.pdf (216.1 KB)
export-without-insert.pdf (167.1 KB)