diff --git a/CLAUDE.md b/CLAUDE.md
index 95ad9642..742a604f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -153,7 +153,10 @@ schermgroepering: gelijknamigen krijgen `#n`, naamlozen een surrogaat), daaronde
start; relaties staan bij dit type uit omdat een taak onder meerdere banden kan staan; optie "blad
per resource" = `PrintOptions.pageBreakBeforeGroups`
→ `RenderReportResult.forcedBreakOffsets` → `forcedBreakOffsetsPx` in `tileLayout`, waar een gedwongen
-positie zonder vulgraaddrempel wint; `isGanttReportType()` bundelt beide Gantt-achtige typen), het
+positie zonder vulgraaddrempel wint; `isGanttReportType()` bundelt beide Gantt-achtige typen; de voet
+met legenda is sinds #113 net als de kop een herhaalbaar blok — `RenderReportResult.footerHeight` →
+`repeatFooterHeightPx`/`repeatFooter`, instelling `repeatFooter` standaard aan; let op: de preview
+rendert per pagina één volledige `renderReport`-pass extra voor die strook, net als voor de kop), het
mijlpalen- en variance-rapport (eigen DOM-component + `build*Columns` voor de PDF) en zeven **tabelrapporten**
uit discussie #31 — look-ahead, kritiek/near-critical, voortgang, planningsgezondheid,
resourcebelasting per week, resourcetoewijzingen en WBS-samenvatting. Die zeven hebben een pure
diff --git a/artifacts/resourcediagram/03-voet-op-elke-pagina-pagina-2.png b/artifacts/resourcediagram/03-voet-op-elke-pagina-pagina-2.png
new file mode 100644
index 00000000..127cda26
Binary files /dev/null and b/artifacts/resourcediagram/03-voet-op-elke-pagina-pagina-2.png differ
diff --git a/public/docs/en/gids-rapporten-printen.md b/public/docs/en/gids-rapporten-printen.md
index 46795d3a..e3471561 100644
--- a/public/docs/en/gids-rapporten-printen.md
+++ b/public/docs/en/gids-rapporten-printen.md
@@ -40,6 +40,14 @@ A full, formatted printout of the Gantt bars — this is the only report type wi
independently of the zoom level above.
- **Repeat header on each page** — on by default; keeps the report header visible on every printed
page instead of only the first.
+- **Repeat footer on each page** — on by default; puts the footer with project name, print date and
+ legend at the bottom of every page instead of only the last, so a sheet handed out on its own
+ carries its own legend — also when the timeline is spread over several pages side by side. If
+ the whole print fits on one page, the footer simply stays below the last row. The strip costs
+ space on every page: roughly two rows fewer per sheet, so occasionally one page more, and a
+ resource that just fitted on one sheet with *Each resource on a new page* can spill onto two.
+ Off = the footer on the last page only. The page number ("3 / 7") always sits in the bottom
+ margin, separate from the footer; the footer itself no longer carries a page number.
- **Timeline over** — spreads the Gantt timeline across 1 to 8 pages side by side; only available
with auto-fit on. Choose more pages when you want a less compressed timeline without reducing
the table text.
@@ -102,8 +110,8 @@ resource yourself first. Bands are per resource, not per name: two resources tha
name each get their own band (*Jan #1*, *Jan #2*), and a resource without a name gets a sequence
number. The summary block counts the resources, the assignments and the tasks without a resource —
that last count includes milestones and hammocks, because they are drawn here (the *Resource
-assignments* table report counts real activities only). The footer with the legend and print date
-sits, as in every multi-page print, on the last sheet. If there are no assignments yet, the preview
+assignments* table report counts real activities only). With *Repeat footer on each page* (on by
+default) every sheet you hand out carries its own legend. If there are no assignments yet, the preview
says so instead of showing an empty page; assigning happens on the **Resources** tab (see
[Resources & histogram](docs://gids-resources-histogram)).
diff --git a/public/docs/nl/gids-rapporten-printen.md b/public/docs/nl/gids-rapporten-printen.md
index 7fb2bc7a..14a8cef7 100644
--- a/public/docs/nl/gids-rapporten-printen.md
+++ b/public/docs/nl/gids-rapporten-printen.md
@@ -41,6 +41,14 @@ instellingenblok:
van het zoomniveau hierboven.
- **Kop op elke pagina herhalen** — standaard aan; houdt de rapportkop zichtbaar op elke afgedrukte
pagina in plaats van alleen de eerste.
+- **Voet op elke pagina herhalen** — standaard aan; zet de voet met projectnaam, afdrukdatum en
+ legenda onderaan elke pagina in plaats van alleen de laatste, zodat een los uitgedeeld vel zijn
+ eigen legenda heeft — ook wanneer de tijdlijn over meerdere pagina's naast elkaar staat. Past de
+ hele afdruk op één pagina, dan blijft de voet gewoon onder de laatste rij staan. De strook kost
+ per pagina ruimte: ongeveer twee rijen minder per vel, dus soms een pagina meer, en een resource
+ die met *Elke resource op een nieuwe pagina* nét op één vel paste kan daardoor over twee vellen
+ gaan. Uit = de voet alleen op de laatste pagina. Het paginanummer ("3 / 7") staat altijd in de
+ ondermarge, los van de voet; de voet zelf draagt geen paginanummer meer.
- **Tijdlijn over** — verdeelt de Gantt-tijdlijn over 1 tot 8 pagina's naast elkaar; alleen
beschikbaar met auto-fit aan. Kies meer pagina's wanneer je de tijdas minder wilt comprimeren
zonder de tabeltekst kleiner te maken.
@@ -105,8 +113,8 @@ niet eerst zelf op resource te groeperen. Banden zijn per resource, niet per naa
toevallig hetzelfde heten krijgen elk een eigen band (*Jan #1*, *Jan #2*), en een resource zonder
naam een volgnummer. Het overzichtsblok telt de resources, de toewijzingen en de taken zonder
resource — die laatste telling omvat ook mijlpalen en hammocks, want die worden hier getekend (het
-tabelrapport *Resourcetoewijzingen* telt alleen echte activiteiten). De voet met legenda en
-afdrukdatum staat, zoals bij elke meerpagina-afdruk, op het laatste vel. Zijn er nog geen
+tabelrapport *Resourcetoewijzingen* telt alleen echte activiteiten). Met *Voet op elke pagina
+herhalen* (standaard aan) heeft elk uitgedeeld vel zijn eigen legenda. Zijn er nog geen
toewijzingen, dan zegt het voorbeeld dat in plaats van een lege pagina te tonen; toewijzen gebeurt
op het tabblad **Resources** (zie [Resources & histogram](docs://gids-resources-histogram)).
diff --git a/src/components/panels/ReportPanel.tsx b/src/components/panels/ReportPanel.tsx
index f4d9e970..54901789 100644
--- a/src/components/panels/ReportPanel.tsx
+++ b/src/components/panels/ReportPanel.tsx
@@ -6,8 +6,8 @@ import { computePreviewRasterLimits } from '@/services/print/previewSafety';
import { getLocalizedMonths, getLocalizedMonthsShort } from '@/i18n/dateFormat';
import { projectFileBase } from '@/utils/documents';
import { computeHighResScale } from '@/utils/miniPdf';
-import { paginateCanvasToPdfBytes } from '@/services/print/paginate';
-import { computeTileLayout } from '@/services/print/tileLayout';
+import { paginateCanvasToPdfBytes, type PaginateOptions } from '@/services/print/paginate';
+import { computeTileLayout, footerLayoutWidthFor } from '@/services/print/tileLayout';
import { ensureInterLoaded, getInterFontBytes, getArabicFontBytes } from '@/services/pdf/fontLoader';
import { RTL_LOCALES, type Locale } from '@/i18n/config';
import { Select } from '@/components/common/Select';
@@ -305,6 +305,9 @@ export function ReportPanel() {
// Bewust géén veld in `PrintOptions`: de kopherhaling is puur een pagineerder-zaak (raster:
// hoogte in px; vector: boolean), niet iets dat de render-zoom raakt.
const [repeatHeader, setRepeatHeader] = useState(DEFAULT_REPORT_SETTINGS.repeatHeader);
+ // Voet (projectnaam, afdrukdatum, legenda) op elke pagina — issue #113: een blad per persoon
+ // zonder legenda is onleesbaar. Zelfde pagineerder-zaak als de kop, dus óók geen PrintOptions-veld.
+ const [repeatFooter, setRepeatFooter] = useState(DEFAULT_REPORT_SETTINGS.repeatFooter);
// Issue #25 punt 5 — smeert de tijdlijn uit over N paginabreedtes (1 = oud gedrag, geen
// verrassing voor bestaande gebruikers). Alleen zinvol in fit-width-modus; daarom `disabled`
// wanneer `autoFit` uit staat (dan tegelt de export in 'actual'-modus toch al horizontaal).
@@ -387,6 +390,7 @@ export function ReportPanel() {
setPaperSize(s.paperSize);
setOrientation(s.orientation);
setRepeatHeader(s.repeatHeader);
+ setRepeatFooter(s.repeatFooter);
setTimelineColumns(s.timelineColumns);
setReportFontScale(s.reportFontScale);
setStatusLine(s.statusLine);
@@ -427,14 +431,14 @@ export function ReportPanel() {
void saveReportSettings({
reportType, showCritical, showFloat, showDeps, showWeekends, compressNonWorkdays: reportCompressNonWorkdays, showLegend,
showTaskNames, showCompletion, truncateTaskNames, taskNameColumnWidth, showBaselineOverlay, autoFit, customZoom,
- paperSize, orientation, repeatHeader, timelineColumns, reportFontScale, statusLine, followView, previewQuality,
+ paperSize, orientation, repeatHeader, repeatFooter, timelineColumns, reportFontScale, statusLine, followView, previewQuality,
tableReports: tableOptions,
resourceGantt: resourceGanttOptions,
}).catch(() => {});
}, [reportType, showCritical, showFloat, showDeps, showWeekends, reportCompressNonWorkdays, showLegend, showTaskNames,
showCompletion, truncateTaskNames, taskNameColumnWidth, showBaselineOverlay, autoFit, customZoom, paperSize,
- orientation, repeatHeader, timelineColumns, reportFontScale, statusLine, followView, previewQuality, tableOptions,
- resourceGanttOptions]);
+ orientation, repeatHeader, repeatFooter, timelineColumns, reportFontScale, statusLine, followView, previewQuality,
+ tableOptions, resourceGanttOptions]);
// Resourcediagram (issue #113): dezelfde Gantt-render, maar de rijen komen uit de pure rekenmodule
// (per resource-identiteit een band, daaronder zijn taken) en niet van het scherm.
@@ -548,8 +552,6 @@ export function ReportPanel() {
duration: t('tableHeaders.duration'),
completion: t('tableHeaders.completion', { defaultValue: 'Volt.' }),
},
- page: t('page', { defaultValue: 'Pagina' }),
- of: t('of', { defaultValue: 'van' }),
today: t('today', { defaultValue: 'Vandaag' }),
statusDate: t('statusDateLabel', { defaultValue: 'Statusdatum' }),
progressDate: t('progressDateLabel', { defaultValue: 'Voortgangsdatum' }),
@@ -647,7 +649,7 @@ export function ReportPanel() {
const renderPreview = () => {
if (cancelled) return;
const {
- width: logicalWidth, height: logicalHeight, tableWidth, headerHeight, breakOffsets, forcedBreakOffsets,
+ width: logicalWidth, height: logicalHeight, tableWidth, headerHeight, footerHeight, breakOffsets, forcedBreakOffsets,
} = measurePrintReport(tasks, sequences, calendar, projectName, options);
const lowerPaper = options.paperSize.toLowerCase() as 'a4' | 'a3' | 'a2' | 'a1';
const cssPageWidth = previewCssWidth;
@@ -665,6 +667,7 @@ export function ReportPanel() {
// Kop herhalen per pagina (issue #25 punt 1): de hoogte komt uit de render zelf; 0 = niet
// herhalen (oud gedrag). De raster-tak wil px, de vector-tak een boolean.
repeatHeaderHeightPx: repeatHeader ? headerHeight : 0,
+ repeatFooterHeightPx: repeatFooter ? footerHeight : 0,
timelineColumns: options.timelineColumns,
// Rij-bewuste paginering (issue #110): preview en export delen dezelfde breekposities;
// het resourcediagram (issue #113) ook zijn gedwongen overgangen per resource.
@@ -673,6 +676,9 @@ export function ReportPanel() {
supersample: previewLimits.pageSupersample,
};
const layout = computeTileLayout(tileOptions);
+ // De herhaalde voet wordt binnen één paginabreedte gelegd (meerdere kolommen ⇒ compleet op elk
+ // vel); zonder herhaling blijft de render exact de oude (voet over de volle canvasbreedte).
+ const pageOptions: PrintOptions = { ...options, footerLayoutWidth: footerLayoutWidthFor(layout) };
const total = layout.rows * layout.cols;
const root = previewViewportRef.current;
const anchor = root ? capturePreviewScrollAnchor(root) : { index: 0, offset: 0, scrollTop: 0 };
@@ -752,7 +758,7 @@ export function ReportPanel() {
const canvas = document.createElement('canvas');
let pendingObjectUrl: string | undefined;
try {
- renderPrintPreviewPage(canvas, tasks, sequences, calendar, projectName, options, {
+ renderPrintPreviewPage(canvas, tasks, sequences, calendar, projectName, pageOptions, {
layout,
pageIndex: index,
rasterWidth: previewLimits.pageRasterWidth,
@@ -828,7 +834,7 @@ export function ReportPanel() {
// toevoegen zou iedere preview-state-update opnieuw laten rasteren.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reportSettingsHydrated, isGanttLike, tasks, sequences, calendar, projectName, previewOptionsSignature,
- repeatHeader, previewCssWidth, previewQuality, replacePreviewPages]);
+ repeatHeader, repeatFooter, previewCssWidth, previewQuality, replacePreviewPages]);
// Eén stabiele observer per layout. Een nieuwe afbeelding verandert zijn dependencies niet en kan
// dus geen observer-rebuild/ping-pong veroorzaken. De queue dedupliceert callbacks.
@@ -919,20 +925,25 @@ export function ReportPanel() {
const exportRaster = (): Uint8Array => {
const exportCanvas = document.createElement('canvas');
const {
- width: logicalWidth, height: logicalHeight, tableWidth, headerHeight, breakOffsets, forcedBreakOffsets,
+ width: logicalWidth, height: logicalHeight, tableWidth, headerHeight, footerHeight, breakOffsets, forcedBreakOffsets,
} = renderPrintCanvas(exportCanvas, tasks, sequences, calendar, projectName, options, 1);
- const exportScale = computeHighResScale(logicalWidth, logicalHeight);
- renderPrintCanvas(exportCanvas, tasks, sequences, calendar, projectName, options, exportScale);
- return paginateCanvasToPdfBytes(exportCanvas, {
+ const rasterTile: PaginateOptions = {
paperSize: lowerPaper, orientation, mode,
logicalWidth, logicalHeight, frozenColumnWidthPx: tableWidth,
- // Zelfde kopherhaling (px) en tijdlijn-spreiding als de preview en de vector-tak, zodat de
- // raster-terugval WYSIWYG gelijk is aan beide (issue #25 punt 1 + 5).
+ // Zelfde kop-/voetherhaling (px) en tijdlijn-spreiding als de preview en de vector-tak,
+ // zodat de raster-terugval WYSIWYG gelijk is aan beide (issue #25 punt 1 + 5, #113).
repeatHeaderHeightPx: repeatHeader ? headerHeight : 0,
+ repeatFooterHeightPx: repeatFooter ? footerHeight : 0,
timelineColumns,
breakOffsetsPx: breakOffsets,
forcedBreakOffsetsPx: forcedBreakOffsets,
- });
+ };
+ // De high-res render legt de voet binnen één paginabreedte (zie `footerLayoutWidth`) — alleen
+ // wanneer hij herhaald wordt; anders is dit letterlijk de oude render.
+ const exportScale = computeHighResScale(logicalWidth, logicalHeight);
+ renderPrintCanvas(exportCanvas, tasks, sequences, calendar, projectName,
+ { ...options, footerLayoutWidth: footerLayoutWidthFor(computeTileLayout(rasterTile)) }, exportScale);
+ return paginateCanvasToPdfBytes(exportCanvas, rasterTile);
};
// Vector-tak (fase 2): échte vector-PDF met selecteerbare tekst + ingebedde Inter. Bij een fout
@@ -948,14 +959,16 @@ export function ReportPanel() {
getArabicFontBytes(700),
]);
pdfBytes = await paginateVectorToPdfBytes(
- (make) => renderReport(make, tasks, sequences, calendar, projectName, options),
+ (make, footerLayoutWidth) => renderReport(make, tasks, sequences, calendar, projectName, { ...options, footerLayoutWidth }),
{
paperSize: lowerPaper,
orientation,
mode,
baseDir: exportBaseDir,
- // Kop per pagina herhalen (issue #25 punt 1) + tijdlijn over N pagina's (punt 5).
+ // Kop per pagina herhalen (issue #25 punt 1) + tijdlijn over N pagina's (punt 5); voet
+ // per pagina (issue #113).
repeatHeader,
+ repeatFooter,
timelineColumns,
},
{ regular, bold },
@@ -1061,7 +1074,7 @@ export function ReportPanel() {
await writePdf(tablePdfBytes, `${fileBase}-${suffix}.pdf`);
}, [reportType, isGanttLike, projectName, fileBase, tasks, sequences, calendar, options, paperSize, orientation,
- autoFit, repeatHeader, timelineColumns, writePdf, t, dd, milestoneRows, varianceResult, tableSpec]);
+ autoFit, repeatHeader, repeatFooter, timelineColumns, writePdf, t, dd, milestoneRows, varianceResult, tableSpec]);
// K7-guard: een stale planning eerst doorrekenen. NIET meteen daarna exporteren — `runExport`
// leest `tasks`/`options`/`tableSpec` uit de closure van de HUIDIGE render, en die kent de
@@ -1433,6 +1446,10 @@ export function ReportPanel() {
setRepeatHeader(e.target.checked)} className="accent-accent flex-shrink-0" />
{t('repeatHeader')}
+
+ setRepeatFooter(e.target.checked)} className="accent-accent flex-shrink-0" data-ops-report-repeat-footer />
+ {t('repeatFooter')}
+
setShowTaskNames(e.target.checked)} className="accent-accent flex-shrink-0" />
diff --git a/src/i18n/locales/ar/report.json b/src/i18n/locales/ar/report.json
index 345157e6..c0253a50 100644
--- a/src/i18n/locales/ar/report.json
+++ b/src/i18n/locales/ar/report.json
@@ -25,6 +25,7 @@
"zoom": "تكبير:",
"previewQuality": { "label": "جودة المعاينة", "standard": "قياسي", "high": "عالية", "maximum": "قصوى" },
"repeatHeader": "تكرار الرأس في كل صفحة",
+ "repeatFooter": "تكرار التذييل في كل صفحة",
"reportFontScaleLabel": "حجم الخط:",
"timelineColumnsLabel": "الجدول الزمني عبر:",
"timelineColumnsHint": "فقط مع الملاءمة التلقائية",
@@ -41,8 +42,6 @@
"exportPDF": "تصدير PDF",
"noTasks": "لا توجد مهام لعرضها",
"printed": "طبع في:",
- "page": "صفحة",
- "of": "من",
"today": "اليوم",
"previewPageLimit_zero": "… و{{count}} صفحة أخرى — صدّر للحصول على المستند الكامل",
"previewPageLimit_one": "… وصفحة واحدة أخرى — صدّر للحصول على المستند الكامل",
diff --git a/src/i18n/locales/de/report.json b/src/i18n/locales/de/report.json
index 1791371d..bc446182 100644
--- a/src/i18n/locales/de/report.json
+++ b/src/i18n/locales/de/report.json
@@ -25,6 +25,7 @@
"zoom": "Zoom:",
"previewQuality": { "label": "Vorschauqualität", "standard": "Standard", "high": "Hoch", "maximum": "Maximal" },
"repeatHeader": "Kopfzeile auf jeder Seite wiederholen",
+ "repeatFooter": "Fußzeile auf jeder Seite wiederholen",
"reportFontScaleLabel": "Schriftgröße:",
"timelineColumnsLabel": "Zeitleiste über:",
"timelineColumnsHint": "Nur bei automatischer Anpassung",
@@ -37,8 +38,6 @@
"exportPDF": "PDF exportieren",
"noTasks": "Keine Aufgaben vorhanden",
"printed": "Gedruckt:",
- "page": "Seite",
- "of": "von",
"today": "Heute",
"previewPageLimit_one": "… und {{count}} weitere Seite — für das vollständige Dokument exportieren",
"previewPageLimit_other": "… und {{count}} weitere Seiten — für das vollständige Dokument exportieren",
diff --git a/src/i18n/locales/en/report.json b/src/i18n/locales/en/report.json
index 556dfe33..6d9c4a90 100644
--- a/src/i18n/locales/en/report.json
+++ b/src/i18n/locales/en/report.json
@@ -25,6 +25,7 @@
"zoom": "Zoom:",
"previewQuality": { "label": "Preview quality", "standard": "Standard", "high": "High", "maximum": "Maximum" },
"repeatHeader": "Repeat header on each page",
+ "repeatFooter": "Repeat footer on each page",
"reportFontScaleLabel": "Font size:",
"timelineColumnsLabel": "Timeline over:",
"timelineColumnsHint": "Only with auto-fit",
@@ -37,8 +38,6 @@
"exportPDF": "Export PDF",
"noTasks": "No tasks to display",
"printed": "Printed:",
- "page": "Page",
- "of": "of",
"today": "Today",
"previewPageLimit_one": "… and {{count}} more page — export for the full document",
"previewPageLimit_other": "… and {{count}} more pages — export for the full document",
diff --git a/src/i18n/locales/es/report.json b/src/i18n/locales/es/report.json
index 7fc0842f..268a889d 100644
--- a/src/i18n/locales/es/report.json
+++ b/src/i18n/locales/es/report.json
@@ -25,6 +25,7 @@
"zoom": "Zoom:",
"previewQuality": { "label": "Calidad de vista previa", "standard": "Estándar", "high": "Alta", "maximum": "Máxima" },
"repeatHeader": "Repetir encabezado en cada página",
+ "repeatFooter": "Repetir pie de página en cada página",
"reportFontScaleLabel": "Tamaño de letra:",
"timelineColumnsLabel": "Cronograma en:",
"timelineColumnsHint": "Solo con ajuste automático",
@@ -38,8 +39,6 @@
"exportPDF": "Exportar PDF",
"noTasks": "No hay tareas para mostrar",
"printed": "Impreso:",
- "page": "Página",
- "of": "de",
"today": "Hoy",
"previewPageLimit_one": "… y {{count}} página más — exporta para ver el documento completo",
"previewPageLimit_many": "… y {{count}} páginas más — exporta para ver el documento completo",
diff --git a/src/i18n/locales/fa/report.json b/src/i18n/locales/fa/report.json
index 1115ba9b..33e6bee1 100644
--- a/src/i18n/locales/fa/report.json
+++ b/src/i18n/locales/fa/report.json
@@ -25,6 +25,7 @@
"zoom": "بزرگنمایی:",
"previewQuality": { "label": "کیفیت پیشنمایش", "standard": "استاندارد", "high": "بالا", "maximum": "حداکثر" },
"repeatHeader": "تکرار سرصفحه در هر صفحه",
+ "repeatFooter": "تکرار پانویس در هر صفحه",
"reportFontScaleLabel": "اندازه قلم:",
"timelineColumnsLabel": "خط زمانی روی:",
"timelineColumnsHint": "تنها با تنظیم خودکار",
@@ -37,8 +38,6 @@
"exportPDF": "خروجی PDF",
"noTasks": "وظیفهای برای نمایش وجود ندارد",
"printed": "چاپ شده:",
- "page": "صفحه",
- "of": "از",
"today": "امروز",
"previewPageLimit_one": "… و {{count}} صفحه دیگر — برای سند کامل خروجی بگیرید",
"previewPageLimit_other": "… و {{count}} صفحه دیگر — برای سند کامل خروجی بگیرید",
diff --git a/src/i18n/locales/fr/report.json b/src/i18n/locales/fr/report.json
index 9c1df1eb..e4bec476 100644
--- a/src/i18n/locales/fr/report.json
+++ b/src/i18n/locales/fr/report.json
@@ -25,6 +25,7 @@
"zoom": "Zoom :",
"previewQuality": { "label": "Qualité de l’aperçu", "standard": "Standard", "high": "Élevée", "maximum": "Maximale" },
"repeatHeader": "Répéter l'en-tête sur chaque page",
+ "repeatFooter": "Répéter le pied de page sur chaque page",
"reportFontScaleLabel": "Taille de police :",
"timelineColumnsLabel": "Chronologie sur :",
"timelineColumnsHint": "Uniquement avec ajustement automatique",
@@ -38,8 +39,6 @@
"exportPDF": "Exporter en PDF",
"noTasks": "Aucune tâche à afficher",
"printed": "Imprimé :",
- "page": "Page",
- "of": "sur",
"today": "Aujourd’hui",
"previewPageLimit_one": "… et {{count}} page de plus — exportez pour le document complet",
"previewPageLimit_many": "… et {{count}} pages de plus — exportez pour le document complet",
diff --git a/src/i18n/locales/it/report.json b/src/i18n/locales/it/report.json
index 0aef21b6..8f373168 100644
--- a/src/i18n/locales/it/report.json
+++ b/src/i18n/locales/it/report.json
@@ -25,6 +25,7 @@
"zoom": "Zoom:",
"previewQuality": { "label": "Qualità anteprima", "standard": "Standard", "high": "Alta", "maximum": "Massima" },
"repeatHeader": "Ripeti intestazione su ogni pagina",
+ "repeatFooter": "Ripeti piè di pagina su ogni pagina",
"reportFontScaleLabel": "Dimensione carattere:",
"timelineColumnsLabel": "Cronologia su:",
"timelineColumnsHint": "Solo con adattamento automatico",
@@ -38,8 +39,6 @@
"exportPDF": "Esporta PDF",
"noTasks": "Nessuna attività da visualizzare",
"printed": "Stampato:",
- "page": "Pagina",
- "of": "di",
"today": "Oggi",
"previewPageLimit_one": "… e {{count}} pagina in più — esporta per il documento completo",
"previewPageLimit_many": "… e {{count}} pagine in più — esporta per il documento completo",
diff --git a/src/i18n/locales/ja/report.json b/src/i18n/locales/ja/report.json
index f5366b02..165c5993 100644
--- a/src/i18n/locales/ja/report.json
+++ b/src/i18n/locales/ja/report.json
@@ -25,6 +25,7 @@
"zoom": "ズーム:",
"previewQuality": { "label": "プレビュー品質", "standard": "標準", "high": "高", "maximum": "最大" },
"repeatHeader": "各ページにヘッダーを繰り返す",
+ "repeatFooter": "各ページにフッターを繰り返す",
"reportFontScaleLabel": "文字サイズ:",
"timelineColumnsLabel": "タイムライン:",
"timelineColumnsHint": "自動調整時のみ",
@@ -36,8 +37,6 @@
"exportPDF": "PDF エクスポート",
"noTasks": "表示するタスクがありません",
"printed": "印刷日:",
- "page": "ページ",
- "of": "/",
"today": "今日",
"previewPageLimit_other": "… 他 {{count}} ページ — 完全な文書はエクスポートしてください",
"legend": {
diff --git a/src/i18n/locales/ko/report.json b/src/i18n/locales/ko/report.json
index 075d3820..0988039c 100644
--- a/src/i18n/locales/ko/report.json
+++ b/src/i18n/locales/ko/report.json
@@ -25,6 +25,7 @@
"zoom": "확대/축소:",
"previewQuality": { "label": "미리 보기 품질", "standard": "표준", "high": "높음", "maximum": "최대" },
"repeatHeader": "각 페이지에 머리글 반복",
+ "repeatFooter": "각 페이지에 바닥글 반복",
"reportFontScaleLabel": "글자 크기:",
"timelineColumnsLabel": "타임라인:",
"timelineColumnsHint": "자동 맞춤일 때만",
@@ -36,8 +37,6 @@
"exportPDF": "PDF 내보내기",
"noTasks": "표시할 작업이 없습니다",
"printed": "인쇄일:",
- "page": "페이지",
- "of": "/",
"today": "오늘",
"previewPageLimit_other": "… 외 {{count}}페이지 — 전체 문서를 보려면 내보내세요",
"legend": {
diff --git a/src/i18n/locales/nl/report.json b/src/i18n/locales/nl/report.json
index 6589acd1..348db8b4 100644
--- a/src/i18n/locales/nl/report.json
+++ b/src/i18n/locales/nl/report.json
@@ -25,6 +25,7 @@
"zoom": "Zoomen:",
"previewQuality": { "label": "Previewkwaliteit", "standard": "Standaard", "high": "Hoog", "maximum": "Maximaal" },
"repeatHeader": "Kop op elke pagina herhalen",
+ "repeatFooter": "Voet op elke pagina herhalen",
"reportFontScaleLabel": "Lettergrootte:",
"timelineColumnsLabel": "Tijdlijn over:",
"timelineColumnsHint": "Alleen bij automatische passing",
@@ -37,8 +38,6 @@
"exportPDF": "Exporteer PDF",
"noTasks": "Geen taken om weer te geven",
"printed": "Afgedrukt:",
- "page": "Pagina",
- "of": "van",
"today": "Vandaag",
"previewPageLimit_one": "… en nog {{count}} pagina — exporteer voor het volledige document",
"previewPageLimit_other": "… en nog {{count}} pagina's — exporteer voor het volledige document",
diff --git a/src/i18n/locales/pl/report.json b/src/i18n/locales/pl/report.json
index 56c6b60f..0e37087e 100644
--- a/src/i18n/locales/pl/report.json
+++ b/src/i18n/locales/pl/report.json
@@ -25,6 +25,7 @@
"zoom": "Powiększenie:",
"previewQuality": { "label": "Jakość podglądu", "standard": "Standardowa", "high": "Wysoka", "maximum": "Maksymalna" },
"repeatHeader": "Powtarzaj nagłówek na każdej stronie",
+ "repeatFooter": "Powtarzaj stopkę na każdej stronie",
"reportFontScaleLabel": "Rozmiar czcionki:",
"timelineColumnsLabel": "Oś czasu na:",
"timelineColumnsHint": "Tylko przy automatycznym dopasowaniu",
@@ -39,8 +40,6 @@
"exportPDF": "Eksportuj PDF",
"noTasks": "Brak zadań do wyświetlenia",
"printed": "Wydrukowano:",
- "page": "Strona",
- "of": "z",
"today": "Dzisiaj",
"previewPageLimit_one": "… i jeszcze {{count}} strona — wyeksportuj, aby zobaczyć cały dokument",
"previewPageLimit_few": "… i jeszcze {{count}} strony — wyeksportuj, aby zobaczyć cały dokument",
diff --git a/src/i18n/locales/pt/report.json b/src/i18n/locales/pt/report.json
index c47f79d5..0603dd6e 100644
--- a/src/i18n/locales/pt/report.json
+++ b/src/i18n/locales/pt/report.json
@@ -25,6 +25,7 @@
"zoom": "Zoom:",
"previewQuality": { "label": "Qualidade da pré-visualização", "standard": "Padrão", "high": "Alta", "maximum": "Máxima" },
"repeatHeader": "Repetir cabeçalho em cada página",
+ "repeatFooter": "Repetir rodapé em cada página",
"reportFontScaleLabel": "Tamanho da fonte:",
"timelineColumnsLabel": "Cronograma em:",
"timelineColumnsHint": "Apenas com ajuste automático",
@@ -38,8 +39,6 @@
"exportPDF": "Exportar PDF",
"noTasks": "Não há tarefas para apresentar",
"printed": "Impresso:",
- "page": "Página",
- "of": "de",
"today": "Hoje",
"previewPageLimit_one": "… e mais {{count}} página — exporte para o documento completo",
"previewPageLimit_many": "… e mais {{count}} páginas — exporte para o documento completo",
diff --git a/src/i18n/locales/tr/report.json b/src/i18n/locales/tr/report.json
index d3395204..404581ea 100644
--- a/src/i18n/locales/tr/report.json
+++ b/src/i18n/locales/tr/report.json
@@ -25,6 +25,7 @@
"zoom": "Yakınlaştırma:",
"previewQuality": { "label": "Önizleme kalitesi", "standard": "Standart", "high": "Yüksek", "maximum": "Maksimum" },
"repeatHeader": "Üstbilgiyi her sayfada yinele",
+ "repeatFooter": "Altbilgiyi her sayfada yinele",
"reportFontScaleLabel": "Yazı boyutu:",
"timelineColumnsLabel": "Zaman çizelgesi:",
"timelineColumnsHint": "Yalnızca otomatik sığdırmada",
@@ -37,8 +38,6 @@
"exportPDF": "PDF dışa aktar",
"noTasks": "Görüntülenecek görev yok",
"printed": "Yazdırıldı:",
- "page": "Sayfa",
- "of": "/",
"today": "Bugün",
"previewPageLimit_one": "… ve {{count}} sayfa daha — tam belge için dışa aktarın",
"previewPageLimit_other": "… ve {{count}} sayfa daha — tam belge için dışa aktarın",
diff --git a/src/i18n/locales/zh/report.json b/src/i18n/locales/zh/report.json
index 88eaebae..ccc53e10 100644
--- a/src/i18n/locales/zh/report.json
+++ b/src/i18n/locales/zh/report.json
@@ -25,6 +25,7 @@
"zoom": "缩放:",
"previewQuality": { "label": "预览质量", "standard": "标准", "high": "高", "maximum": "最高" },
"repeatHeader": "在每页重复页眉",
+ "repeatFooter": "在每页重复页脚",
"reportFontScaleLabel": "字号:",
"timelineColumnsLabel": "时间轴跨:",
"timelineColumnsHint": "仅在自动适配时可用",
@@ -36,8 +37,6 @@
"exportPDF": "导出 PDF",
"noTasks": "没有可显示的任务",
"printed": "打印时间:",
- "page": "第",
- "of": "页,共",
"today": "今天",
"previewPageLimit_other": "… 还有 {{count}} 页 — 导出以查看完整文档",
"legend": {
diff --git a/src/services/pdf/pdfTable.ts b/src/services/pdf/pdfTable.ts
index 66c88700..da65d1bb 100644
--- a/src/services/pdf/pdfTable.ts
+++ b/src/services/pdf/pdfTable.ts
@@ -241,7 +241,7 @@ export function makeTableRenderReport(
// `headerHeight: 0` — een tabel-render heeft géén herhaalbare kopstrook: titel + kolomkoppen
// staan bewust alleen bovenaan het eerste vel (de pagineerder herhaalt niets als dit 0 is).
- return { width: tableWidth, height, tableWidth: 0, headerHeight: 0, breakOffsets };
+ return { width: tableWidth, height, tableWidth: 0, headerHeight: 0, footerHeight: 0, breakOffsets };
};
}
@@ -334,6 +334,6 @@ export function makeSectionedRenderReport(
d2d.textAlign = 'left';
d2d.textBaseline = 'alphabetic';
- return { width, height, tableWidth: 0, headerHeight: 0, breakOffsets };
+ return { width, height, tableWidth: 0, headerHeight: 0, footerHeight: 0, breakOffsets };
};
}
diff --git a/src/services/print/paginate.ts b/src/services/print/paginate.ts
index d16832ea..834f89a3 100644
--- a/src/services/print/paginate.ts
+++ b/src/services/print/paginate.ts
@@ -47,6 +47,12 @@ export interface PaginateOptions {
* default hier houdt alleen deze module gedragsneutraal voor andere/toekomstige aanroepers.
*/
repeatHeaderHeightPx?: number;
+ /**
+ * Hoogte (LOGISCHE px, vanaf de ONDERkant van de bron) van de voetstrook — projectnaam,
+ * afdrukdatum, legenda — die op ELKE pagina onderaan herhaald wordt. Vul hier
+ * `renderPrintCanvas().footerHeight` in; 0/afwezig = alleen op de laatste pagina (oud gedrag).
+ */
+ repeatFooterHeightPx?: number;
/**
* Aantal paginabreedtes waarover de tijdlijn uitgesmeerd wordt (issue #25 punt 5). Alleen in
* `'fit-width'`; default 1 = alles op één paginabreedte persen (oud gedrag).
@@ -121,6 +127,13 @@ export function paginateCanvasToTile(
drawTile(ctx, canvas, srcScale, win.srcX, row.srcY, win.srcW, row.srcH,
destXpx, layout.bodyTopPt * pxPt, destWpx, row.srcH * layout.scale * pxPt);
}
+ // Voetstrook: één keer per pagina uit het vaste `footerWindow` (x vanaf 0, één paginabreedte) —
+ // niet per kolomvenster gesneden, anders staat de legenda alleen in de kolom waar hij toevallig valt.
+ if (layout.repeatFooterPx > 0) {
+ const fw = layout.footerWindow;
+ drawTile(ctx, canvas, srcScale, fw.srcX, layout.repeatFooterSrcY, fw.srcW, layout.repeatFooterPx,
+ fw.pageX * pxPt, layout.footerTopPt * pxPt, fw.srcW * layout.scale * pxPt, layout.repeatFooterPtH * pxPt);
+ }
ctx.fillStyle = '#999999';
ctx.font = `${Math.round(8 * pxPt)}px sans-serif`;
ctx.textAlign = 'right';
diff --git a/src/services/print/paginateVector.ts b/src/services/print/paginateVector.ts
index 1b9887fc..42733be2 100644
--- a/src/services/print/paginateVector.ts
+++ b/src/services/print/paginateVector.ts
@@ -26,7 +26,7 @@ import { subsetFont } from '@/services/pdf/hbSubset';
import { appLog } from '@/services/debug/appLog';
import type { Draw2D } from '@/services/pdf/draw2d';
import type { RenderReportResult } from '@/services/print/printPreview';
-import { computeTileLayout, type PaperSize, type Orientation, type PaginateMode } from './tileLayout';
+import { computeTileLayout, footerLayoutWidthFor, type PaperSize, type Orientation, type PaginateMode } from './tileLayout';
/** Optie-subset voor de vector-pagineerder; de logische dims + bevroren-kolombreedte komen uit de render. */
export interface VectorPaginateOptions {
@@ -47,6 +47,12 @@ export interface VectorPaginateOptions {
* hem daarom uit het render-resultaat ({@link RenderReportResult.headerHeight}).
*/
repeatHeader?: boolean;
+ /**
+ * Herhaal de voetstrook (projectnaam, afdrukdatum, legenda) onderaan ELKE pagina. Zelfde
+ * boolean-vorm als `repeatHeader`, om dezelfde reden: de hoogte komt uit
+ * {@link RenderReportResult.footerHeight}. Default false = voet alleen op de laatste pagina.
+ */
+ repeatFooter?: boolean;
/**
* Aantal paginabreedtes waarover de tijdlijn uitgesmeerd wordt (issue #25 punt 5). Alleen in
* `'fit-width'`; default 1 = alles op één paginabreedte persen (oud gedrag).
@@ -103,13 +109,16 @@ export class VectorUnsupportedError extends Error {
/**
* Bouw een multi-page VECTOR-PDF uit een print-render.
*
- * @param renderReport wordt exact één keer aangeroepen met een Draw2D-fabriek; levert de logische
- * dims + bevroren-kolombreedte. Sluit `tasks/sequences/calendar/...` in.
+ * @param renderReport wordt aangeroepen met een Draw2D-fabriek (één keer, plus een extra pass voor
+ * CJK-fonts of voor de voetbreedte — zie hieronder); levert de logische dims +
+ * bevroren-kolombreedte. Sluit `tasks/sequences/calendar/...` in. De tweede
+ * parameter is `PrintOptions.footerLayoutWidth`: de breedte (logische px) die
+ * op één pagina past, zodat de herhaalde voet op elke kolompagina compleet is.
* @param opts papierformaat/oriëntatie/modus/marge.
* @param fontBytes rauwe Inter-TTF-bytes (Regular + Bold) voor de subset-embedding.
*/
export async function paginateVectorToPdfBytes(
- renderReport: (makeDraw2D: (w: number, h: number) => Draw2D) => RenderReportResult,
+ renderReport: (makeDraw2D: (w: number, h: number) => Draw2D, footerLayoutWidth?: number) => RenderReportResult,
opts: VectorPaginateOptions,
fontBytes: InterFontBytes,
arabicBytes: ArabicFontBytes,
@@ -140,12 +149,14 @@ export async function paginateVectorToPdfBytes(
// Render-helper: draai `renderReport` één keer met een verse PdfVectorDraw2D die de gegeven CJK-fonts
// kent. Beide passes delen dezelfde `pool` (alpha-/font-dedup is idempotent, dus pass 2 hergebruikt de
// resource-sleutels van pass 1). Geeft de laatste capture + de logische dims terug.
- const runRender = (cjk: CjkVectorFont[]): { d2d: PdfVectorDraw2D; dims: RenderReportResult } => {
+ let lastCjk: CjkVectorFont[] = [];
+ const runRender = (cjk: CjkVectorFont[], footerLayoutWidth?: number): { d2d: PdfVectorDraw2D; dims: RenderReportResult } => {
let captured: PdfVectorDraw2D | null = null;
+ lastCjk = cjk;
const dims = renderReport((w, h) => {
captured = new PdfVectorDraw2D(w, h, pool, baseDir, arabicFonts, cjk);
return captured;
- });
+ }, footerLayoutWidth);
if (!captured) throw new Error('paginateVectorToPdfBytes: renderReport riep de Draw2D-fabriek niet aan');
return { d2d: captured as PdfVectorDraw2D, dims };
};
@@ -256,6 +267,45 @@ export async function paginateVectorToPdfBytes(
({ d2d, dims } = runRender(cjkPass2));
}
+ // ---- Tegel-/schaalwiskunde: gedeeld met de raster-pagineerder via `tileLayout.computeTileLayout` ----
+ // Stond hier vroeger als letterlijke kopie van de raster-pagineerder; nu één bron van waarheid,
+ // zodat preview (raster) en export (vector) niet uit elkaar kunnen lopen.
+ const tileInput = (d: RenderReportResult) => ({
+ paperSize: opts.paperSize,
+ orientation: opts.orientation,
+ mode: opts.mode,
+ logicalWidth: d.width,
+ logicalHeight: d.height,
+ frozenColumnWidthPx: d.tableWidth,
+ // De kop- en voetstrookhoogte komen uit de render zelf (zie `VectorPaginateOptions.repeatHeader`).
+ repeatHeaderHeightPx: opts.repeatHeader ? d.headerHeight : 0,
+ repeatFooterHeightPx: opts.repeatFooter ? d.footerHeight : 0,
+ timelineColumns: opts.timelineColumns,
+ marginPt: opts.marginPt,
+ breakOffsetsPx: d.breakOffsets,
+ forcedBreakOffsetsPx: d.forcedBreakOffsets,
+ });
+ let layout = computeTileLayout(tileInput(dims));
+
+ // Voet op elke pagina: de voetinhoud moet binnen één paginabreedte gelegd zijn (anders staat bij
+ // meerdere kolommen het merk in kolom N en de legenda in kolom 2). De render kent die breedte pas
+ // ná de tegelwiskunde, dus één extra pass met `footerLayoutWidth` — alleen wanneer die breedte
+ // écht kleiner is dan het canvas. In fit-width met één kolom zijn beide analytisch gelijk
+ // (`col0Bodypx = printW/scale = cw`) maar als floats soms 1e-13 uit elkaar; zonder de marge draaide
+ // ~5 % van de exports een tweede volledige render voor niets (review #135 ronde 3, F1). De maten
+ // veranderen niet door de pass (de voet bepaalt de canvasbreedte niet); de layout wordt toch
+ // herrekend. Een smallere voet kan alleen legenda-items wéglaten, dus nieuwe codepoints zijn
+ // uitgesloten — de coverage-poort wordt hieronder tóch herhaald, zodat die invariant geen
+ // ongeschreven aanname blijft (F2).
+ const footerLayoutWidth = footerLayoutWidthFor(layout);
+ if (footerLayoutWidth !== undefined && footerLayoutWidth < dims.width - 0.5) {
+ ({ d2d, dims } = runRender(lastCjk, footerLayoutWidth));
+ layout = computeTileLayout(tileInput(dims));
+ if (d2d.uncoveredCodepoints.size > 0) {
+ throw new VectorUnsupportedError([...d2d.uncoveredCodepoints], d2d.hasRtl);
+ }
+ }
+
// Eén Form-XObject met alle VORMEN (grid/staven/arcering) + eigen font/ExtGState-resources (G1: de
// tekening wordt exact één keer vastgelegd en per pagina ge-`Do`'d). De TEKST zit BEWUST niet in het
// XObject (fase 2.1): een gedeeld XObject `Do`'t z'n volledige tekstlaag op élke pagina, waardoor de
@@ -269,24 +319,10 @@ export async function paginateVectorToPdfBytes(
const xobjRef = doc.context.register(xobj);
const texts = d2d.texts;
- // ---- Tegel-/schaalwiskunde: gedeeld met de raster-pagineerder via `tileLayout.computeTileLayout` ----
- // Stond hier vroeger als letterlijke kopie van de raster-pagineerder; nu één bron van waarheid,
- // zodat preview (raster) en export (vector) niet uit elkaar kunnen lopen.
- const layout = computeTileLayout({
- paperSize: opts.paperSize,
- orientation: opts.orientation,
- mode: opts.mode,
- logicalWidth: dims.width,
- logicalHeight: dims.height,
- frozenColumnWidthPx: dims.tableWidth,
- // De kopstrookhoogte komt uit de render zelf (zie `VectorPaginateOptions.repeatHeader`).
- repeatHeaderHeightPx: opts.repeatHeader ? dims.headerHeight : 0,
- timelineColumns: opts.timelineColumns,
- marginPt: opts.marginPt,
- breakOffsetsPx: dims.breakOffsets,
- forcedBreakOffsetsPx: dims.forcedBreakOffsets,
- });
- const { pageWidthPt: pageW, pageHeightPt: pageH, marginPt, scale, rows, cols, repeatHeaderPx, bodyTopPt } = layout;
+ const {
+ pageWidthPt: pageW, pageHeightPt: pageH, marginPt, scale, rows, cols, repeatHeaderPx, bodyTopPt,
+ repeatFooterPx, repeatFooterSrcY, footerTopPt, footerWindow,
+ } = layout;
const ch = dims.height;
const totalPages = rows * cols;
@@ -344,6 +380,7 @@ export async function paginateVectorToPdfBytes(
// Top van het printgebied in PDF-punten (y-omhoog); de kopstrook begint hier, de body eronder.
const printTopYUp = pageH - marginPt;
const bodyTopYUp = pageH - bodyTopPt;
+ const footerTopYUp = pageH - footerTopPt;
let pageIndex = 0;
for (const row of layout.bodyRows) {
@@ -375,6 +412,12 @@ export async function paginateVectorToPdfBytes(
}
drawTile(pageOps, win.srcX, row.srcY, win.srcW, row.srcH, win.pageX, bodyTopYUp);
}
+ // Voetstrook onderaan het printgebied, één keer per pagina uit het vaste `footerWindow` (x vanaf
+ // 0, één paginabreedte — niet het kolomvenster); de voettekst valt in dat bronvenster en wordt
+ // dus — net als de koptekst — per pagina geëmit.
+ if (repeatFooterPx > 0) {
+ drawTile(pageOps, footerWindow.srcX, repeatFooterSrcY, footerWindow.srcW, repeatFooterPx, footerWindow.pageX, footerTopYUp);
+ }
// Paginanummer rechtsonder in de marge (grijs ~8pt), als vector-tekst — buiten het XObject.
const label = `${pageIndex} / ${totalPages}`;
diff --git a/src/services/print/printPreview.ts b/src/services/print/printPreview.ts
index fedcf492..ce1b4095 100644
--- a/src/services/print/printPreview.ts
+++ b/src/services/print/printPreview.ts
@@ -328,8 +328,6 @@ export interface PrintOptions {
relationStyle: string;
};
tableHeaders: { wbs: string; taskName: string; start: string; end: string; duration: string; completion: string };
- page: string;
- of: string;
/** Label boven de gestippelde "vandaag"-lijn in het Gantt-gebied. */
today: string;
/** Label boven de statusdatum-/voortgangslijn in de exportkop (#54). */
@@ -411,6 +409,14 @@ export interface PrintOptions {
* `rows` met bandrijen is er niets te breken en is dit een no-op.
*/
pageBreakBeforeGroups?: boolean;
+ /**
+ * Breedte (logische px, vanaf x = 0) waarbinnen de VOETinhoud gelegd wordt — naam/datum links,
+ * legenda in het midden, merk rechts. Ontbreekt ⇒ de volle canvasbreedte (één kolom). De
+ * pagineerders geven hier `TileLayout.footerLayoutWidthPx` door (één paginabreedte) zodra de
+ * afdruk meer dan één kolom telt: de herhaalde voet wordt uit één vast bronvenster getekend en
+ * moet dus op één pagina compleet zijn. De grijze achtergrondstrook blijft canvasbreed.
+ */
+ footerLayoutWidth?: number;
/** Legendalabels voor de kleurmodi (reeds vertaald door de aanroeper — print heeft geen `t()`). */
barColorsLegendLabels?: {
criticalOutline: string;
@@ -576,6 +582,14 @@ export interface RenderReportResult {
* module niet hoeft te kennen. 0 = geen herhaalbare kop (bv. de lege-project-render).
*/
headerHeight: number;
+ /**
+ * Hoogte (LOGISCHE px, gemeten vanaf de ONDERkant van de render) van de voetstrook: projectnaam,
+ * afdrukdatum, legenda. De pagineerders herhalen precies deze strook onderaan elke pagina wanneer
+ * daarom gevraagd wordt (`repeatFooter`). 0 = geen herhaalbare voet (tabelrenders, de lege-
+ * project-render). Verplicht, net als `headerHeight`: een renderer die het vergeet moet de
+ * compiler tegenhouden, niet stil "geen voet" opleveren.
+ */
+ footerHeight: number;
/**
* OPTIONEEL — toegestane paginabreekposities (logische px vanaf de bovenkant), bv. de onderrand
* van elke tabelrij (`pdfTable.ts`). De pagineerders eindigen een pagina dan op de laatste
@@ -630,7 +644,7 @@ export function renderReport(
d2d.fillText(options.labels?.noTasks ?? 'No tasks to display', 300, 100);
// Geen kop-/tijdschaalstrook in de lege-staat (alleen een centrale melding) ⇒ niets te herhalen.
// Het meldingsvak zelf houdt z'n vaste 600×200; alleen de tekst erin volgt de schaal.
- return { width: 600, height: 200, tableWidth: m.tableWidth, headerHeight: 0 };
+ return { width: 600, height: 200, tableWidth: m.tableWidth, headerHeight: 0, footerHeight: 0 };
}
// Compute date range
@@ -1196,6 +1210,7 @@ export function renderReport(
: undefined;
return {
width: canvasWidth, height: canvasHeight, tableWidth: m.tableWidth, headerHeight: m.totalHeaderHeight,
+ footerHeight: m.footerHeight,
breakOffsets, ...(forcedBreakOffsets && forcedBreakOffsets.length > 0 ? { forcedBreakOffsets } : {}),
};
}
@@ -1352,6 +1367,17 @@ export function renderPrintPreviewPage(
layout.bodyTopPt * pxPt,
);
}
+ // Voetstrook: één keer per pagina uit het vaste `footerWindow` (zie tileLayout), niet per kolomvenster.
+ if (layout.repeatFooterPx > 0) {
+ renderWindow(
+ layout.footerWindow.srcX,
+ layout.repeatFooterSrcY,
+ layout.footerWindow.srcW,
+ layout.repeatFooterPx,
+ layout.footerWindow.pageX * pxPt,
+ layout.footerTopPt * pxPt,
+ );
+ }
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#999999';
@@ -2101,6 +2127,9 @@ function drawFooter(
// Alles in de voettekst is tekst-zone: de marge, de regelafstanden en de legenda-blokjes schalen
// mee met de strookhoogte, anders staan de twee regels bij 125% over elkaar.
const pad = m.s(10);
+ // De inhoud wordt binnen `footerLayoutWidth` gelegd (één paginabreedte bij meerdere kolommen);
+ // de achtergrondstrook blijft canvasbreed.
+ const layoutWidth = Math.min(canvasWidth, options.footerLayoutWidth && options.footerLayoutWidth > 0 ? options.footerLayoutWidth : canvasWidth);
// Background
d2d.fillStyle = PRINT_COLORS.surface;
@@ -2132,24 +2161,19 @@ function drawFooter(
d2d.fillText(dateText, pad, midY + m.s(8));
const leftBlockRight = pad + Math.max(leftNameW, d2d.measureText(dateText).width);
- // Right: Page number + branding (breedtes meten, dan tekenen)
- const pageLabel = options.labels?.page ?? 'Pagina';
- const ofLabel = options.labels?.of ?? 'van';
- const pageText = `${pageLabel} 1 ${ofLabel} 1`;
+ // Right: branding. Hier stond ook een vast "Pagina 1 van 1": de render kent het paginatotaal
+ // niet, en nu de voet op elke pagina terugkomt zou dat op pagina 3 van 5 letterlijk zo staan.
+ // Het echte "n / totaal" drukken de pagineerders zelf in de ondermarge.
const brandText = 'Open Planner Studio';
- d2d.font = m.font(9);
- const pageW = d2d.measureText(pageText).width;
d2d.font = m.font(8);
const brandW = d2d.measureText(brandText).width;
- const rightBlockLeft = canvasWidth - pad - Math.max(pageW, brandW);
+ const rightBlockLeft = layoutWidth - pad - brandW;
d2d.fillStyle = PRINT_COLORS.textSecondary;
d2d.textAlign = 'right';
d2d.textBaseline = 'middle';
- d2d.font = m.font(9);
- d2d.fillText(pageText, canvasWidth - pad, midY - m.s(6));
d2d.font = m.font(8);
- d2d.fillText(brandText, canvasWidth - pad, midY + m.s(8));
+ d2d.fillText(brandText, layoutWidth - pad, midY + m.s(8));
// Center: Legend — dynamisch tussen het linker- en rechterblok, items weglaten bij te weinig
// ruimte i.p.v. over de blokken heen tekenen (klacht 7).
diff --git a/src/services/print/tileLayout.ts b/src/services/print/tileLayout.ts
index 21ccc5bd..1f5fcec2 100644
--- a/src/services/print/tileLayout.ts
+++ b/src/services/print/tileLayout.ts
@@ -19,14 +19,19 @@
* 1. VERTICAAL — de body wordt over `rows` pagina's verdeeld. Is `repeatHeaderHeightPx` gezet, dan
* wordt de bronstrook `y ∈ [0, repeatHeaderHeightPx)` (project-/tijdschaalkop) op ELKE
* verticale tegel bovenaan de pagina herhaald en begint de body daaronder (issue #25 punt 1).
+ * Is `repeatFooterHeightPx` gezet én telt de afdruk meer dan één pagina, dan wordt de
+ * bronstrook `y ∈ [logicalHeight - repeatFooterHeightPx, logicalHeight)` (projectnaam, datum,
+ * legenda) op elke pagina onderaan het printgebied herhaald — uit het vaste bronvenster
+ * `footerWindow` (x vanaf 0, één paginabreedte), níét per kolom gesneden, zodat de legenda op
+ * elke kolompagina staat (issue #113, "een blad per persoon").
* 2. HORIZONTAAL in `'actual'`-modus — vaste CSS→papierverhouding (0,75 pt per CSS-px), dus
* zoveel kolommen als de bron breed is zonder de tabel groter te drukken dan in auto-fit.
* 3. HORIZONTAAL in `'fit-width'`-modus — normaal 1 kolom (alles op één paginabreedte geperst),
* maar met `timelineColumns: N` wordt de tijdlijn bewust over N paginabreedtes uitgesmeerd
* (issue #25 punt 5) zodat hij leesbaar blijft.
*
- * Bij `repeatHeaderHeightPx: 0` en `timelineColumns: 1` reproduceert deze functie exact de oude
- * uitkomst; die twee defaults zijn dus het "oude gedrag" van deze ENGINE.
+ * Bij `repeatHeaderHeightPx: 0`, `repeatFooterHeightPx: 0` en `timelineColumns: 1` reproduceert
+ * deze functie exact de oude uitkomst; die drie defaults zijn dus het "oude gedrag" van deze ENGINE.
*
* Dat is uitdrukkelijk geen belofte over wat de gebruiker ziet: het rapportpaneel
* (`ReportPanel.tsx`) zet de knop "kop herhalen" sinds issue #25 punt 1 bewust standaard AAN en
@@ -76,8 +81,9 @@ export function printableWidthLogicalPx(
return printableWidthPt(paperSize, orientation, marginPt) / LOGICAL_PX_TO_PT;
}
-/** Ruimte onderaan (punten) gereserveerd voor het paginanummer in de marge. */
-export const FOOTER_PT = 14;
+/** Ruimte onderaan (punten) gereserveerd voor het paginanummer in de marge — niet te verwarren met
+ * de herhaalde voetstrook van het rapport (`repeatFooter*`), die bóven deze ruimte staat. */
+export const PAGE_NUMBER_PT = 14;
/** Bovengrens voor {@link TileLayoutInput.timelineColumns}. Zie de afdwinging in
* {@link computeTileLayout}: het paginatotaal loopt kwadratisch in N, dus een ongebonden waarde
@@ -105,6 +111,18 @@ export interface TileLayoutInput {
* punt 1 wél — zie de module-doc hierboven).
*/
repeatHeaderHeightPx?: number;
+ /**
+ * Hoogte (logische px, gemeten vanaf de ONDERkant van de bron) van de voetstrook — projectnaam,
+ * afdrukdatum, legenda — die op elke pagina onderaan het printgebied herhaald wordt. Default 0 =
+ * geen herhaling: de voet blijft dan onder de laatste rij hangen en komt alleen op de laatste
+ * pagina terecht (het gedrag van vóór deze optie). De body-tegels lopen dan tot `logicalHeight`;
+ * mét herhaling tot `logicalHeight - repeatFooterHeightPx`, en elke pagina krijgt de strook apart
+ * uit `footerWindow`. Past de hele afdruk op één pagina (en dwingt niets een tweede af), dan valt
+ * er niets te herhalen en blijft de voet gewoon onder de laatste rij — anders zou de meest
+ * voorkomende afdruk ineens twintig centimeter wit tussen tabel en voet krijgen.
+ * Reden (issue #113, "een blad per persoon"): een uitdeelvel zonder legenda is onleesbaar.
+ */
+ repeatFooterHeightPx?: number;
/**
* OPTIONEEL — toegestane breekposities (y in logische px vanaf de bovenkant van de bron) waar een
* pagina mag eindigen; typisch de onderrand van elke tabelrij. Zie de rij-bewuste tegeling in
@@ -149,7 +167,7 @@ export interface TileColumn {
xWindows: TileXWindow[];
}
-/** Eén verticale body-tegel (de kopstrook zit hier NIET in; die wordt per pagina apart herhaald). */
+/** Eén verticale body-tegel (kop- en voetstrook zitten hier NIET in; die worden per pagina apart herhaald). */
export interface TileBodyRow {
/** Bovenrand van het bron-venster (logische px). */
srcY: number;
@@ -157,6 +175,16 @@ export interface TileBodyRow {
srcH: number;
}
+/**
+ * De breedte waarbinnen de render de voetinhoud moet leggen (`PrintOptions.footerLayoutWidth`),
+ * of `undefined` wanneer de voet niet herhaald wordt — dan hangt hij aan de body en hoort hij, net
+ * als vóór deze optie, over de volle canvasbreedte te liggen. Eén helper voor preview, raster- én
+ * vector-pagineerder, zodat de drie paden niet uiteenlopen (review #135 ronde 2, B2).
+ */
+export function footerLayoutWidthFor(layout: Pick): number | undefined {
+ return layout.repeatFooterPx > 0 ? layout.footerLayoutWidthPx : undefined;
+}
+
/** Volledig uitgerekende pagina-indeling; beide backends tekenen hier 1:1 uit. */
export interface TileLayout {
/** Papierbreedte in punten (honoreert oriëntatie). */
@@ -183,6 +211,27 @@ export interface TileLayout {
repeatHeaderPx: number;
/** Diezelfde kopstrook in punten op de pagina (`repeatHeaderPx * scale`). */
repeatHeaderPtH: number;
+ /** Effectieve voetstrookhoogte (logische px); 0 = geen voetherhaling. */
+ repeatFooterPx: number;
+ /** Diezelfde voetstrook in punten op de pagina (`repeatFooterPx * scale`). */
+ repeatFooterPtH: number;
+ /** Bovenrand van de voetstrook in de BRON (logische px) = `logicalHeight - repeatFooterPx`. */
+ repeatFooterSrcY: number;
+ /**
+ * Het horizontale bronvenster van de voetstrook: altijd vanaf x = 0, één paginabreedte breed
+ * (`min(logicalWidth, footerLayoutWidthPx)`), op `marginPt` — op ELKE pagina hetzelfde, ook in
+ * kolom 2..N. De render legt de voetinhoud binnen `footerLayoutWidthPx` (zie
+ * `PrintOptions.footerLayoutWidth`), zodat naam, legenda en merk op elk vel compleet zijn.
+ */
+ footerWindow: TileXWindow;
+ /** Breedte (logische px) die op één pagina past = de breedte waarbinnen de voet gelegd hoort. */
+ footerLayoutWidthPx: number;
+ /**
+ * y (punten, vanaf de BOVENkant van het papier) waar de herhaalde voetstrook begint: onderaan het
+ * printgebied, boven de paginanummer-marge. Zonder voetherhaling gelijk aan de onderrand van het
+ * printgebied (nergens gebruikt).
+ */
+ footerTopPt: number;
/**
* y (punten, gerekend vanaf de BOVENkant van het papier) waar de body-tegel begint: onder de
* marge én onder de eventueel herhaalde kopstrook. De kopstrook zelf begint op `marginPt`.
@@ -211,7 +260,7 @@ export function computeTileLayout(input: TileLayoutInput): TileLayout {
const pageHeightPt = input.orientation === 'landscape' ? base.width : base.height;
const printW = pageWidthPt - 2 * marginPt;
- const printH = pageHeightPt - 2 * marginPt - FOOTER_PT;
+ const printH = pageHeightPt - 2 * marginPt - PAGE_NUMBER_PT;
// Bron-afmetingen in LOGISCHE px — alle tegel-wiskunde gebeurt in deze eenheid.
//
@@ -293,7 +342,29 @@ export function computeTileLayout(input: TileLayoutInput): TileLayout {
? repeatRequestedPx
: 0;
const repeatHeaderPtH = repeatHeaderPx * scale;
- const bodyRowHpx = pageSrcHpx - repeatHeaderPx;
+ // Voetherhaling: dezelfde degeneratie-vangnetten als de kop, nu gegeven de (al toegepaste) kop —
+ // er moet body overblijven, op de pagina én in de bron. En alleen als er écht iets te herhalen
+ // valt: past de bron (kop meegerekend) op één pagina, tegelt hij niet horizontaal (`cols > 1` is
+ // óók meer dan één vel — review #135 ronde 2, B1) en dwingt geen gedwongen breekpositie een tweede
+ // pagina af, dan blijft de voet aan de laatste rij hangen zoals altijd. De gedwongen posities
+ // worden hier getoetst tegen `ch - voet`: een positie ín de voetstrook zou de herhaling aanzetten
+ // maar daarna als breekpositie wegvallen (`inBody` filtert op `bodyEnd`), en dat gat mag niet.
+ const footerRequestedPx = Math.max(0, input.repeatFooterHeightPx ?? 0);
+ const forcesSecondPage = (input.forcedBreakOffsetsPx ?? [])
+ .some(y => Number.isFinite(y) && y > repeatHeaderPx && y < ch - footerRequestedPx);
+ const multiPage = ch > pageSrcHpx || cols > 1 || forcesSecondPage;
+ const repeatFooterPx = footerRequestedPx > 0
+ && multiPage
+ && repeatHeaderPx + footerRequestedPx < pageSrcHpx
+ && repeatHeaderPx + footerRequestedPx < ch
+ ? footerRequestedPx
+ : 0;
+ const repeatFooterPtH = repeatFooterPx * scale;
+ const repeatFooterSrcY = ch - repeatFooterPx;
+ // De body loopt van onder de kop tot boven de voet; zonder voetherhaling is dat tot `ch` — dan is
+ // alles hieronder byte-identiek aan de tegeling van vóór deze optie.
+ const bodyEnd = repeatFooterSrcY;
+ const bodyRowHpx = pageSrcHpx - repeatHeaderPx - repeatFooterPx;
const bodyRows: TileBodyRow[] = [];
// Rij-bewuste paginering (issue #110 punt 3): levert de render toegestane breekposities (y in
// logische px, bv. de onderrand van elke tabelrij), dan eindigt een body-tegel op de LAATSTE
@@ -304,21 +375,21 @@ export function computeTileLayout(input: TileLayoutInput): TileLayout {
// snede een flinterdunne restpagina (review-bevinding 10). Voor gewone tabel-/Gantt-rijen (tientallen
// px op een pagina van honderden) is die drempel nooit bindend. Zonder breekposities (de
// DOM-screenshot-fallback) is dit byte-identiek de oude vaste tegeling.
- const inBody = (y: number) => Number.isFinite(y) && y > repeatHeaderPx && y < ch;
+ const inBody = (y: number) => Number.isFinite(y) && y > repeatHeaderPx && y < bodyEnd;
const breaks = (input.breakOffsetsPx ?? []).filter(inBody).sort((x, y) => x - y);
// Gedwongen posities (issue #113) zijn ook toegestane posities: wie hier breekt, mag daar breken.
const forced = (input.forcedBreakOffsetsPx ?? []).filter(inBody).sort((x, y) => x - y);
const allowed = forced.length > 0 ? [...new Set([...breaks, ...forced])].sort((x, y) => x - y) : breaks;
let srcY = repeatHeaderPx;
- while (srcY < ch || bodyRows.length === 0) {
- const maxEnd = Math.min(ch, srcY + bodyRowHpx);
+ while (srcY < bodyEnd || bodyRows.length === 0) {
+ const maxEnd = Math.min(bodyEnd, srcY + bodyRowHpx);
let end = maxEnd;
// Eerste gedwongen positie ná de tegelstart: past hij op de pagina, dan eindigt de tegel dáár —
// zonder vulgraaddrempel, want een dunne pagina is hier precies de bedoeling.
const nextForced = forced.find(y => y > srcY);
if (nextForced !== undefined && nextForced <= maxEnd) {
end = nextForced;
- } else if (allowed.length > 0 && maxEnd < ch) {
+ } else if (allowed.length > 0 && maxEnd < bodyEnd) {
let best = -1;
for (const y of allowed) {
if (y <= srcY) continue;
@@ -371,7 +442,13 @@ export function computeTileLayout(input: TileLayoutInput): TileLayout {
frozenPtW,
repeatHeaderPx,
repeatHeaderPtH,
+ repeatFooterPx,
+ repeatFooterPtH,
+ repeatFooterSrcY,
+ footerWindow: { srcX: 0, srcW: Math.min(col0Bodypx, cw), pageX: marginPt },
+ footerLayoutWidthPx: col0Bodypx,
bodyTopPt: marginPt + repeatHeaderPtH,
+ footerTopPt: marginPt + printH - repeatFooterPtH,
columns,
bodyRows,
};
diff --git a/src/utils/reportSettings.ts b/src/utils/reportSettings.ts
index 26e6170c..a0e31dd7 100644
--- a/src/utils/reportSettings.ts
+++ b/src/utils/reportSettings.ts
@@ -153,6 +153,8 @@ export interface ReportSettings {
paperSize: ReportPaperSize;
orientation: ReportOrientation;
repeatHeader: boolean;
+ /** Voet (projectnaam, afdrukdatum, legenda) op elke pagina — anders alleen op de laatste. */
+ repeatFooter: boolean;
timelineColumns: number;
reportFontScale: number;
/** Statuslijn in de export (#54), letterlijk drie opties zoals gevraagd. */
@@ -187,6 +189,8 @@ export const DEFAULT_REPORT_SETTINGS: ReportSettings = {
paperSize: 'A3',
orientation: 'landscape',
repeatHeader: true,
+ // Standaard aan, net als de kop: een uitdeelvel zonder legenda is onleesbaar (issue #113).
+ repeatFooter: true,
timelineColumns: 1,
reportFontScale: 100,
statusLine: 'none',
@@ -296,6 +300,7 @@ export async function loadReportSettings(): Promise {
paperSize: parseEnum(PAPER_SIZES, s.paperSize) ?? d.paperSize,
orientation: parseEnum(ORIENTATIONS, s.orientation) ?? d.orientation,
repeatHeader: parseBoolean(s.repeatHeader) ?? d.repeatHeader,
+ repeatFooter: parseBoolean(s.repeatFooter) ?? d.repeatFooter,
timelineColumns: parseClampedInt(s.timelineColumns, TIMELINE_COLUMNS_MIN, TIMELINE_COLUMNS_MAX) ?? d.timelineColumns,
reportFontScale: parseNumberChoice(FONT_SCALES, s.reportFontScale) ?? d.reportFontScale,
statusLine: parseEnum(STATUS_LINES, s.statusLine) ?? d.statusLine,
diff --git a/tests/browser/report-footer.spec.ts b/tests/browser/report-footer.spec.ts
new file mode 100644
index 00000000..d4059ed3
--- /dev/null
+++ b/tests/browser/report-footer.spec.ts
@@ -0,0 +1,173 @@
+// Voet op elke pagina (issue #113, "een blad per persoon"): met de optie aan krijgt élke pagina van
+// de geëxporteerde vector-PDF de voetstrook (projectnaam, afdrukdatum, legenda) — ook wanneer de
+// tijdlijn over meerdere pagina's naast elkaar staat; uit ⇒ de voet hangt alleen aan de laatste
+// body-tegel. De tegelwiskunde, de voetbreedte en de preview delen dezelfde `TileLayout` (headless
+// bewaakt in check-print-report.ts); hier de echte gebruikersflow: de knop in het paneel, de preview
+// die opnieuw rastert, en het bestand dat de gebruiker in handen krijgt.
+//
+// De controle op de PDF leest per pagina de content-stream: de vector-pagineerder tekent per
+// bronvenster `q x y w h re W n cm /X0 Do …tekst… Q`. De voetstrook is het venster met de
+// clip-rechthoek op de onderrand van het printgebied (y = marge 24 + paginanummerruimte 14), laag
+// (de strook is 50 logische px × schaal — de bovengrens laat de lettergrootte 125% toe; een
+// body-tegel op die y is een volle pagina en dus honderden punten hoog), en — dat is het contract —
+// altijd vanaf x = 24 en één volle printbreedte breed, dus nooit per kolom gesneden. Inhoud: de
+// tekst wordt per venster geëmit ónder dezelfde clip, dus het voetvenster van élke pagina moet
+// tekstoperatoren dragen (projectnaam, afdrukdatum, legenda, merk) — de glyfs zelf zijn
+// gesubset-gecodeerd, dus we tellen `Tj`/`TJ`, niet de letters.
+import { readFile } from 'node:fs/promises';
+import { inflateSync } from 'node:zlib';
+import { PDFArray, PDFDocument, PDFName, PDFRawStream, type PDFPage } from 'pdf-lib';
+import type { Page } from '@playwright/test';
+import { expect, seedProject, test } from './fixtures/ops';
+
+const MARGIN_PT = 24;
+const PAGE_NUMBER_PT = 14;
+
+interface PageShape { width: number; draws: number; footerRects: { x: number; y: number; w: number; h: number; textOps: number }[] }
+
+async function pageShapes(pdfBytes: Uint8Array): Promise {
+ const doc = await PDFDocument.load(pdfBytes);
+ const streamText = (page: PDFPage): string => {
+ const contents = page.node.Contents();
+ if (!contents) return '';
+ const streams = contents instanceof PDFArray
+ ? contents.asArray().map(ref => doc.context.lookup(ref))
+ : [contents];
+ return streams.map(stream => {
+ if (!(stream instanceof PDFRawStream)) return '';
+ const raw = stream.contents;
+ const filter = stream.dict.get(PDFName.of('Filter'));
+ const bytes = filter ? inflateSync(raw) : raw;
+ return Buffer.from(bytes).toString('latin1');
+ }).join('\n');
+ };
+ return doc.getPages().map(page => {
+ const text = streamText(page);
+ // Per tegelvenster (`q x y w h re … Q`, met een stack voor geneste `q`/`Q` van halfdoorzichtige
+ // tekst): de clip-rechthoek en het aantal tekstoperatoren erbinnen.
+ const windows: { x: number; y: number; w: number; h: number; textOps: number }[] = [];
+ const tokens = text.split(/\s+/);
+ let depth = 0;
+ let current: { depth: number; win: typeof windows[number] } | null = null;
+ for (let i = 0; i < tokens.length; i++) {
+ const tok = tokens[i];
+ if (tok === 'q') {
+ depth++;
+ if (!current && tokens[i + 5] === 're') {
+ current = { depth, win: { x: Number(tokens[i + 1]), y: Number(tokens[i + 2]), w: Number(tokens[i + 3]), h: Number(tokens[i + 4]), textOps: 0 } };
+ }
+ } else if (tok === 'Q') {
+ if (current && depth === current.depth) { windows.push(current.win); current = null; }
+ depth--;
+ } else if (current && (tok === 'Tj' || tok === 'TJ')) {
+ current.win.textOps++;
+ }
+ }
+ // Een clip-rechthoek op de onderrand van het printgebied die geen volle pagina hoog is, is de
+ // voetstrook (50 logische px × schaal; bij een smal rapport is de schaal groter dan 0,75, bij
+ // lettergrootte 125 % de strook hoger — vandaar geen krappe band). Een body-tegel die tot die
+ // onderrand reikt is een volle pagina en dus meer dan de halve papierhoogte.
+ const footerRects = windows.filter(r => Math.abs(r.y - (MARGIN_PT + PAGE_NUMBER_PT)) < 0.01 && r.h > 15 && r.h < page.getHeight() / 2);
+ return { width: page.getWidth(), draws: (text.match(/\/X0 Do/g) ?? []).length, footerRects };
+ });
+}
+
+/** Elke pagina: precies één voetvenster, vanaf de linkermarge, één volle printbreedte breed, mét tekst. */
+function expectFullFooterOnEveryPage(shapes: PageShape[]): void {
+ expect(shapes.length).toBeGreaterThan(1);
+ for (const shape of shapes) {
+ expect(shape.footerRects).toHaveLength(1);
+ const footer = shape.footerRects[0];
+ expect(footer.x).toBeCloseTo(MARGIN_PT, 1);
+ expect(footer.w).toBeCloseTo(shape.width - 2 * MARGIN_PT, 1);
+ // Projectnaam, afdrukdatum, merk en minstens één legendalabel: nooit een lege grijze strook.
+ expect(footer.textOps).toBeGreaterThanOrEqual(4);
+ }
+}
+
+async function exportPdf(page: Page): Promise {
+ const downloadPromise = page.waitForEvent('download');
+ await page.getByRole('button', { name: /^(Export PDF|Exporteer PDF)$/ }).click();
+ const download = await downloadPromise;
+ expect(download.suggestedFilename()).toMatch(/-planning\.pdf$/);
+ const path = await download.path();
+ expect(path).not.toBeNull();
+ return new Uint8Array(await readFile(path!));
+}
+
+test('report footer: elke PDF-pagina krijgt de volle voetstrook — ook per tijdlijnkolom — en zonder de optie alleen de laatste', async ({ page, ops: _ops }) => {
+ // Genoeg rijen voor ruim meer dan twee pagina's op A3 liggend.
+ const tasks = Array.from({ length: 160 }, (_, i) => ({
+ name: `Taak ${i + 1}`, start: '2026-09-07', finish: '2026-09-18', durationDays: 10,
+ }));
+ await seedProject(page, tasks, 'Voet per pagina');
+
+ await page.getByRole('button', { name: /^(Report|Rapport)$/ }).click();
+ const pages = page.locator('[data-preview-page]');
+ const firstImg = pages.first().locator('img');
+ await expect(firstImg).toHaveAttribute('src', /^blob:/, { timeout: 20_000 });
+ const pagesWithFooter = await pages.count();
+ expect(pagesWithFooter).toBeGreaterThan(2);
+
+ // Standaard aan: op elke pagina één voetstrook, vanaf de linkermarge en één volle printbreedte breed.
+ const footerToggle = page.locator('[data-ops-report-repeat-footer]');
+ await expect(footerToggle).toBeChecked();
+ const withFooter = await pageShapes(await exportPdf(page));
+ expect(withFooter.length).toBe(pagesWithFooter);
+ expect(withFooter.every(shape => shape.draws === 3)).toBe(true);
+ expectFullFooterOnEveryPage(withFooter);
+
+ // Tijdlijn over 2 pagina's: kolom 2 tekent de bevroren naamstrip + body, maar de voet blijft op
+ // ÉLKE pagina het volle venster vanaf x = 24 — niet de kolomsnede (review #135, bevinding 1).
+ const before2 = await firstImg.getAttribute('src');
+ await page.getByLabel(/^(Timeline over:|Tijdlijn over:)$/).first().click();
+ await page.getByRole('option', { name: /^2 (pages|pagina's)$/ }).click();
+ await expect.poll(() => firstImg.getAttribute('src'), { timeout: 20_000 }).not.toBe(before2);
+ const twoCols = await pageShapes(await exportPdf(page));
+ expect(twoCols.length).toBe(await pages.count());
+ expect(twoCols.length).toBeGreaterThan(withFooter.length);
+ expectFullFooterOnEveryPage(twoCols);
+ await page.getByLabel(/^(Timeline over:|Tijdlijn over:)$/).first().click();
+ await page.getByRole('option', { name: /^1 (page|pagina)$/ }).click();
+ await expect.poll(() => pages.count(), { timeout: 20_000 }).toBe(pagesWithFooter);
+
+ // Uit: de preview rastert opnieuw, en de PDF tekent per pagina alleen kop + body — geen enkele
+ // pagina heeft nog een losse voetstrook. Zonder voet past er niet minder op een pagina.
+ const before = await firstImg.getAttribute('src');
+ await footerToggle.uncheck();
+ await expect.poll(() => firstImg.getAttribute('src'), { timeout: 20_000 }).not.toBe(before);
+ const pagesWithout = await pages.count();
+ expect(pagesWithout).toBeGreaterThan(2);
+ expect(pagesWithout).toBeLessThanOrEqual(pagesWithFooter);
+ const without = await pageShapes(await exportPdf(page));
+ expect(without.length).toBe(pagesWithout);
+ for (const shape of without) {
+ expect(shape.draws).toBe(2);
+ expect(shape.footerRects).toHaveLength(0);
+ }
+});
+
+test('report footer: klein project met de tijdlijn over 2 pagina\'s — één rij hoog, twee vellen breed — krijgt op beide vellen de volle voet', async ({ page, ops: _ops }) => {
+ await seedProject(page, Array.from({ length: 6 }, (_, i) => ({
+ name: `Taak ${i + 1}`, start: '2026-09-07', finish: '2026-10-16', durationDays: 30,
+ })), 'Klein, breed');
+ await page.getByRole('button', { name: /^(Report|Rapport)$/ }).click();
+ const pages = page.locator('[data-preview-page]');
+ const firstImg = pages.first().locator('img');
+ await expect(firstImg).toHaveAttribute('src', /^blob:/, { timeout: 20_000 });
+ await expect(pages).toHaveCount(1);
+ // Eén pagina: de voet blijft onder de laatste rij — geen los voetvenster, wel gewoon kop + body.
+ const single = await pageShapes(await exportPdf(page));
+ expect(single).toHaveLength(1);
+ expect(single[0].draws).toBe(2);
+ expect(single[0].footerRects).toHaveLength(0);
+
+ const before = await firstImg.getAttribute('src');
+ await page.getByLabel(/^(Timeline over:|Tijdlijn over:)$/).first().click();
+ await page.getByRole('option', { name: /^2 (pages|pagina's)$/ }).click();
+ await expect.poll(() => firstImg.getAttribute('src'), { timeout: 20_000 }).not.toBe(before);
+ await expect(pages).toHaveCount(2);
+ const twoCols = await pageShapes(await exportPdf(page));
+ expect(twoCols).toHaveLength(2);
+ expectFullFooterOnEveryPage(twoCols);
+});
diff --git a/tests/browser/report-preview-performance.spec.ts b/tests/browser/report-preview-performance.spec.ts
index e190b0d3..e7fd7654 100644
--- a/tests/browser/report-preview-performance.spec.ts
+++ b/tests/browser/report-preview-performance.spec.ts
@@ -68,7 +68,11 @@ async function firstVisibleAnchor(page: Page): Promise<{ index: number; relative
test('De Vaart-preview houdt vaste pagina-geometrie en een stabiel scrollanker', async ({ page, ops: _ops }) => {
test.setTimeout(60_000);
- await seedProject(page, Array.from({ length: 260 }, (_, i) => ({
+ // 200 rijen: ruim meer dan twee pagina's, maar met marge binnen het previewcachebudget (`maxPages`
+ // = 7 op kwaliteit 300; dit geeft 6), want deze test materialiseert bewust de HELE preview. Met 260
+ // rijen zat het paginatotaal precies op die grens en viel — sinds de voet op elke pagina staat
+ // (issue #113) en er per pagina één strook minder body past — de laatste pagina buiten de cache.
+ await seedProject(page, Array.from({ length: 200 }, (_, i) => ({
name: `Taak ${i + 1}`,
start: `2026-01-${String(1 + (i % 20)).padStart(2, '0')}`,
finish: `2026-03-${String(1 + (i % 20)).padStart(2, '0')}`,
diff --git a/tests/planning/check-dependency-style.ts b/tests/planning/check-dependency-style.ts
index 121f4bd5..df1077b7 100644
--- a/tests/planning/check-dependency-style.ts
+++ b/tests/planning/check-dependency-style.ts
@@ -146,7 +146,7 @@ function labelsOf(over: Partial> = {}): NonNullable = {}): PrintOptions => ({
// De labels komen in het product vanuit ReportPanel. De voortgangsdatum moet een eigen label
// krijgen: met alleen `statusDate` zou de export bij beide lijnsoorten "Statusdatum" afdrukken.
labels: {
- noTasks: '-', printed: '-', page: '-', of: '-', today: '-', statusDate: 'Statusdatum',
+ noTasks: '-', printed: '-', today: '-', statusDate: 'Statusdatum',
progressDate: 'Voortgangsdatum',
legend: {
criticalPath: 'Kritiek pad', normal: 'Normaal', nearCritical: 'Bijna-kritiek',
@@ -502,6 +502,116 @@ const baseOptions = (over: Partial = {}): PrintOptions => ({
`blad per band met tijdlijn over 2 pagina's: 3 × 2 = 6 pagina's (got ${twoCols.rows}×${twoCols.cols})`);
}
+ // Voet op elke pagina (issue #113, "een blad per persoon"): de render meldt zijn voethoogte, de
+ // tegeling houdt onderaan elke pagina die strook vrij (body tot `logicalHeight - voet`), en zonder
+ // de optie is alles byte-identiek — inclusief de oude situatie waarin de voet aan de laatste
+ // body-tegel hangt.
+ {
+ const many = Array.from({ length: 120 }, (_, i) => ({ ...T_NORM, id: `f${i}`, name: `Taak ${i}`, wbsCode: String(i + 1) }));
+ const dims = measurePrintReport(many, [], cal, 'Voet', baseOptions());
+ ok((dims.footerHeight ?? 0) > 0, `Gantt-render meldt een voethoogte (got ${dims.footerHeight})`);
+ const lastBreak = (dims.breakOffsets ?? [])[dims.breakOffsets!.length - 1];
+ ok(Math.abs(lastBreak + dims.footerHeight! - dims.height) < 1e-9, 'de voet is precies het stuk onder de laatste rijgrens');
+ const scaled = measurePrintReport(many, [], cal, 'Voet', baseOptions({ reportFontScale: 125 }));
+ ok(Math.abs(scaled.footerHeight! - dims.footerHeight! * 1.25) < 1e-9, 'de voethoogte schaalt met de rapport-lettergrootte');
+ const tile = {
+ paperSize: 'a4' as const, orientation: 'landscape' as const, mode: 'fit-width' as const,
+ logicalWidth: dims.width, logicalHeight: dims.height, frozenColumnWidthPx: dims.tableWidth,
+ repeatHeaderHeightPx: dims.headerHeight, breakOffsetsPx: dims.breakOffsets,
+ };
+ const plain = computeTileLayout(tile);
+ const withFooter = computeTileLayout({ ...tile, repeatFooterHeightPx: dims.footerHeight });
+ ok(plain.repeatFooterPx === 0 && plain.footerTopPt === plain.marginPt + plain.printH, 'zonder optie: geen voetstrook, voetrand = onderrand printgebied');
+ ok(JSON.stringify(computeTileLayout({ ...tile, repeatFooterHeightPx: 0 }).bodyRows) === JSON.stringify(plain.bodyRows), 'repeatFooterHeightPx 0 ⇒ byte-identiek');
+ const lastPlain = plain.bodyRows[plain.bodyRows.length - 1];
+ ok(Math.abs(lastPlain.srcY + lastPlain.srcH - dims.height) < 1e-9, 'zonder optie hangt de voet aan de laatste body-tegel (oud gedrag)');
+ ok(withFooter.repeatFooterPx === dims.footerHeight && withFooter.repeatFooterSrcY === dims.height - dims.footerHeight!,
+ 'met optie: voetstrook = onderste voethoogte van de bron');
+ const lastFooter = withFooter.bodyRows[withFooter.bodyRows.length - 1];
+ ok(Math.abs(lastFooter.srcY + lastFooter.srcH - withFooter.repeatFooterSrcY) < 1e-9, 'met optie eindigt de laatste body-tegel boven de voet');
+ const set = new Set(dims.breakOffsets);
+ ok(withFooter.bodyRows.slice(0, -1).every(r => set.has(r.srcY + r.srcH)), 'met voet eindigt elke pagina nog steeds op een rijgrens');
+ ok(withFooter.bodyRows.every(r => r.srcH * withFooter.scale <= withFooter.printH - withFooter.repeatHeaderPtH - withFooter.repeatFooterPtH + 1e-9),
+ 'kop + body + voet passen samen in het printgebied');
+ ok(Math.abs(withFooter.footerTopPt - (withFooter.marginPt + withFooter.printH - withFooter.repeatFooterPtH)) < 1e-9, 'voetstrook staat onderaan het printgebied');
+ ok(withFooter.rows >= plain.rows, 'de voet kost hooguit pagina\'s, nooit minder');
+ // Review #135 bevinding 1: de voet wordt uit één vast venster getekend (x vanaf 0, één
+ // paginabreedte) — op elke kolompagina hetzelfde — en de render legt de voetinhoud binnen die
+ // breedte, zodat merk én legenda op elk vel staan, ook met de tijdlijn over meerdere pagina's.
+ const twoCols = computeTileLayout({ ...tile, timelineColumns: 2, repeatFooterHeightPx: dims.footerHeight });
+ ok(twoCols.cols === 2 && twoCols.repeatFooterPx === dims.footerHeight, 'twee kolommen: voet herhaald');
+ ok(twoCols.footerWindow.srcX === 0 && twoCols.footerWindow.pageX === twoCols.marginPt
+ && Math.abs(twoCols.footerWindow.srcW * twoCols.scale - twoCols.printW) < 1e-6,
+ `voetvenster = één paginabreedte vanaf x=0 (got srcW·scale=${twoCols.footerWindow.srcW * twoCols.scale}, printW=${twoCols.printW})`);
+ ok(twoCols.footerLayoutWidthPx < dims.width && twoCols.footerLayoutWidthPx === twoCols.footerWindow.srcW,
+ 'de voet-layoutbreedte is die paginabreedte, kleiner dan het canvas');
+ const narrow = record(many, [], cal, baseOptions({ footerLayoutWidth: twoCols.footerLayoutWidthPx }));
+ const footerTexts = narrow.texts.filter(t => t.y > narrow.dims.height - narrow.dims.footerHeight);
+ ok(footerTexts.some(t => t.text === 'Open Planner Studio') && footerTexts.every(t => t.x <= twoCols.footerLayoutWidthPx + 1e-6),
+ `met footerLayoutWidth staat alle voettekst (merk incl.) binnen één paginabreedte (max x ${Math.max(...footerTexts.map(t => t.x)).toFixed(1)} ≤ ${twoCols.footerLayoutWidthPx.toFixed(1)})`);
+ const wide = record(many, [], cal, baseOptions());
+ ok(wide.texts.some(t => t.text === 'Open Planner Studio' && t.x > twoCols.footerLayoutWidthPx), 'zonder footerLayoutWidth staat het merk rechts op het canvas (controle dat de meting iets meet)');
+ ok(Math.abs(narrow.dims.width - wide.dims.width) < 1e-9 && Math.abs(narrow.dims.height - wide.dims.height) < 1e-9, 'de voetbreedte verandert de canvasmaten niet');
+ // Review #135 bevinding 4: past alles op één pagina, dan valt er niets te herhalen — de voet blijft
+ // onder de laatste rij en de tegeling is byte-identiek aan die zonder optie.
+ const few = Array.from({ length: 5 }, (_, i) => ({ ...T_NORM, id: `s${i}`, name: `Taak ${i}`, wbsCode: String(i + 1) }));
+ const small = measurePrintReport(few, [], cal, 'Eén pagina', baseOptions());
+ const smallTile = { ...tile, logicalWidth: small.width, logicalHeight: small.height, frozenColumnWidthPx: small.tableWidth, repeatHeaderHeightPx: small.headerHeight, breakOffsetsPx: small.breakOffsets };
+ const smallPlain = computeTileLayout(smallTile);
+ const smallFooter = computeTileLayout({ ...smallTile, repeatFooterHeightPx: small.footerHeight });
+ ok(smallPlain.rows === 1 && smallFooter.repeatFooterPx === 0 && JSON.stringify(smallFooter.bodyRows) === JSON.stringify(smallPlain.bodyRows),
+ 'éénpagina-afdruk: voet niet herhaald, tegeling identiek');
+ ok(footerLayoutWidthFor(smallFooter) === undefined && footerLayoutWidthFor(withFooter) === withFooter.footerLayoutWidthPx,
+ 'footerLayoutWidthFor: undefined zonder herhaling (oude render), de paginabreedte mét');
+ // Review #135 ronde 2, B1: één rij hoog maar over meerdere kolommen getegeld is óók meer dan één
+ // vel — dan hoort de voet wél herhaald (en dus binnen één paginabreedte gelegd) te worden.
+ const smallTwoCols = computeTileLayout({ ...smallTile, timelineColumns: 2, repeatFooterHeightPx: small.footerHeight });
+ ok(smallTwoCols.rows === 1 && smallTwoCols.cols === 2 && smallTwoCols.repeatFooterPx === small.footerHeight,
+ `één rij × twee kolommen: voet herhaald (got rows ${smallTwoCols.rows}, cols ${smallTwoCols.cols}, voet ${smallTwoCols.repeatFooterPx})`);
+ ok(Math.abs(smallTwoCols.bodyRows[0].srcY + smallTwoCols.bodyRows[0].srcH - smallTwoCols.repeatFooterSrcY) < 1e-9,
+ 'één rij × twee kolommen: de body-tegel eindigt boven de voet');
+ const smallActual = computeTileLayout({ ...smallTile, mode: 'actual', repeatFooterHeightPx: small.footerHeight });
+ ok(smallActual.cols > 1 ? smallActual.repeatFooterPx === small.footerHeight : smallActual.repeatFooterPx === 0,
+ `'actual' met ${smallActual.cols} kolom(men): voet ${smallActual.cols > 1 ? 'wel' : 'niet'} herhaald`);
+ // B5: een gedwongen positie ín de voetstrook (onbereikbaar voor de echte render, maar de functie
+ // is de bron van waarheid voor beide backends) zet de herhaling niet aan — anders zou hij de
+ // voet loskoppelen en daarna als breekpositie wegvallen. Zonder herhaling is de voet gewoon body
+ // en mag zo'n positie hem als elke andere gedwongen positie afsplitsen; dat is niet deze zaak.
+ const inFooter = computeTileLayout({ ...smallTile, repeatFooterHeightPx: small.footerHeight, forcedBreakOffsetsPx: [small.height - small.footerHeight / 2] });
+ ok(inFooter.repeatFooterPx === 0, 'gedwongen positie in de voetstrook zet de herhaling niet aan');
+ // Degeneratie: een voet die (met de kop) de hele pagina of de hele bron opeet wordt niet herhaald.
+ const tooTall = computeTileLayout({ ...tile, repeatFooterHeightPx: dims.height });
+ ok(tooTall.repeatFooterPx === 0 && JSON.stringify(tooTall.bodyRows) === JSON.stringify(plain.bodyRows), 'te hoge voet ⇒ niet herhaald, tegeling als zonder');
+ // Zonder kopherhaling en in 'actual': dezelfde voetgarantie.
+ for (const variant of [{ repeatHeaderHeightPx: 0 }, { mode: 'actual' as const }]) {
+ const l = computeTileLayout({ ...tile, ...variant, repeatFooterHeightPx: dims.footerHeight });
+ const last = l.bodyRows[l.bodyRows.length - 1];
+ ok(l.repeatFooterPx === dims.footerHeight && Math.abs(last.srcY + last.srcH - l.repeatFooterSrcY) < 1e-9,
+ `voet herhaald in variant ${JSON.stringify(variant)}`);
+ }
+ // Samen met een blad per resource (#113): de gedwongen posities blijven bandgrenzen, de voet komt op elk vel.
+ const bands: ViewRow[] = [];
+ for (let b = 0; b < 3; b++) {
+ bands.push({ kind: 'group', rowKey: `vb${b}`, key: `vb${b}`, label: `Resource ${b}`, count: 2, depth: 0, levelIndex: 0, collapsed: false });
+ for (let i = 0; i < 2; i++) {
+ const task = { ...T_NORM, id: `vb${b}t${i}`, name: `Taak ${b}.${i}` };
+ bands.push({ kind: 'task', rowKey: task.id, task, depth: 1, dimmed: false });
+ }
+ }
+ const perBand = measurePrintReport(bands.flatMap(r => (r.kind === 'task' ? [r.task] : [])), [], cal, 'Blad per resource', baseOptions({ rows: bands, pageBreakBeforeGroups: true }));
+ const l = computeTileLayout({
+ ...tile, logicalWidth: perBand.width, logicalHeight: perBand.height, frozenColumnWidthPx: perBand.tableWidth,
+ repeatHeaderHeightPx: perBand.headerHeight, breakOffsetsPx: perBand.breakOffsets,
+ forcedBreakOffsetsPx: perBand.forcedBreakOffsets, repeatFooterHeightPx: perBand.footerHeight,
+ });
+ ok(l.rows === 3 && l.repeatFooterPx === perBand.footerHeight, `blad per resource mét voet: drie pagina's, elk met voetstrook — de gedwongen posities maken het meerpagina, ook al past alles op één (got ${l.rows}, voet ${l.repeatFooterPx})`);
+ const forcedSet = new Set(perBand.forcedBreakOffsets);
+ ok(l.bodyRows.slice(0, -1).every(r => forcedSet.has(r.srcY + r.srcH)), 'blad per resource mét voet: pagina 1 en 2 eindigen op de bandgrens');
+ // De voet zelf bevat geen nep-paginanummer meer: de pagineerders drukken "n / totaal" in de marge.
+ const rec = record(many.slice(0, 3), [], cal, baseOptions());
+ ok(!rec.texts.some(t => /^(Pagina|Page) 1 (van|of) 1$/.test(t.text)), 'geen vast "Pagina 1 van 1" in de voet');
+ }
+
// Contract pdfTable → tileLayout (issue #110 punt 3): de breekposities die de tabelrender levert
// vallen precies op rijgrenzen, en de pagineerder eindigt elke pagina op zo'n grens.
{
diff --git a/tests/planning/check-print-working-exceptions.ts b/tests/planning/check-print-working-exceptions.ts
index c535729b..f8fbe213 100644
--- a/tests/planning/check-print-working-exceptions.ts
+++ b/tests/planning/check-print-working-exceptions.ts
@@ -70,7 +70,7 @@ const options: PrintOptions = {
autoFit: true, customZoom: 1, paperSize: 'A4', orientation: 'landscape',
companyName: '', labels: {
noTasks: '-', printed: '-', legend: {} as any, tableHeaders: {} as any,
- page: '-', of: '-', today: '-',
+ today: '-',
}, locale: 'nl', reportFontScale: 100,
} as PrintOptions;
diff --git a/tests/planning/check-report-footer-setting.ts b/tests/planning/check-report-footer-setting.ts
new file mode 100644
index 00000000..4288e6d0
--- /dev/null
+++ b/tests/planning/check-report-footer-setting.ts
@@ -0,0 +1,36 @@
+// Voet op elke pagina (issue #113): rapportvoorkeur in ops-reportSettings, naast de kopherhaling.
+// Bewaakt de default (aan — een uitgedeeld vel zonder legenda is onleesbaar), de round-trip door
+// de echte opslagroute en de tolerantie voor een ontbrekende of geprutste waarde (per veld terug op
+// de default; een opslag van vóór deze optie krijgt dus stil de nieuwe default en verliest niets).
+import './domStub';
+import { DEFAULT_REPORT_SETTINGS, loadReportSettings, saveReportSettings } from '@/utils/reportSettings';
+
+const failures: string[] = [];
+const expect = (label: string, got: unknown, want: unknown) => {
+ if (got !== want) failures.push(`${label}: verwacht ${String(want)}, kreeg ${String(got)}`);
+};
+
+expect('standaard staat de voet op elke pagina', DEFAULT_REPORT_SETTINGS.repeatFooter, true);
+
+localStorage.setItem('ops-reportSettings', JSON.stringify({ repeatFooter: false, repeatHeader: false }));
+const loaded = await loadReportSettings();
+expect('opgeslagen voetkeuze wordt geladen', loaded.repeatFooter, false);
+expect('… onafhankelijk van de kopkeuze', loaded.repeatHeader, false);
+
+localStorage.setItem('ops-reportSettings', JSON.stringify({ repeatHeader: false, showLegend: false }));
+const ouder = await loadReportSettings();
+expect('opslag van vóór de optie ⇒ default aan', ouder.repeatFooter, true);
+expect('… en de overige voorkeuren blijven staan', ouder.showLegend, false);
+
+localStorage.setItem('ops-reportSettings', JSON.stringify({ repeatFooter: 'nee' }));
+expect('rommel ⇒ default', (await loadReportSettings()).repeatFooter, true);
+
+await saveReportSettings({ ...DEFAULT_REPORT_SETTINGS, repeatFooter: false });
+expect('voetkeuze overleeft een round-trip', (await loadReportSettings()).repeatFooter, false);
+
+if (failures.length > 0) {
+ console.log(`XX report-footer-setting: ${failures.length} afwijking(en)`);
+ for (const failure of failures) console.log(` - ${failure}`);
+ process.exit(1);
+}
+console.log('OK report-footer-setting: voet-op-elke-pagina blijft bewaard en tolerant geladen');
diff --git a/tests/planning/check-split-bar-render.ts b/tests/planning/check-split-bar-render.ts
index 881653b7..337f6d0e 100644
--- a/tests/planning/check-split-bar-render.ts
+++ b/tests/planning/check-split-bar-render.ts
@@ -431,7 +431,7 @@ const printOptions: PrintOptions = {
autoFit: true, customZoom: 1, paperSize: 'A4', orientation: 'landscape',
companyName: '', labels: {
noTasks: '-', printed: '-', legend: {} as any, tableHeaders: {} as any,
- page: '-', of: '-', today: '-',
+ today: '-',
}, locale: 'nl', reportFontScale: 100,
} as PrintOptions;
diff --git a/tests/planning/check-today-label.ts b/tests/planning/check-today-label.ts
index 894cbf12..4ac628fc 100644
--- a/tests/planning/check-today-label.ts
+++ b/tests/planning/check-today-label.ts
@@ -112,7 +112,7 @@ function labelsOf(loc: string): NonNullable {
noTasks: j.noTasks, printed: j.printed,
legend: j.legend,
tableHeaders: j.tableHeaders,
- page: j.page, of: j.of, today: j.today, statusDate: j.statusDateLabel ?? 'Statusdatum',
+ today: j.today, statusDate: j.statusDateLabel ?? 'Statusdatum',
};
}
diff --git a/tests/planning/run.sh b/tests/planning/run.sh
index 788dc240..abb6f2d7 100755
--- a/tests/planning/run.sh
+++ b/tests/planning/run.sh
@@ -575,6 +575,10 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then
RRGSETTINGSCHECK="$DIR/.report-resource-gantt-setting.mjs"
if bundle_check "$DIR/check-report-resource-gantt-setting.ts" "$RRGSETTINGSCHECK"; then node "$RRGSETTINGSCHECK" || STATUS=1; fi
+ # Voet op elke pagina (issue #113): default aan, round-trip, tolerantie.
+ RFSETTINGSCHECK="$DIR/.report-footer-setting.mjs"
+ if bundle_check "$DIR/check-report-footer-setting.ts" "$RFSETTINGSCHECK"; then node "$RFSETTINGSCHECK" || STATUS=1; fi
+
# Renderer-datumloos-regressie (TODO-item 2026-07-28): `barGeometry` (en `drawMilestone`) gooide
# per frame een TypeError op een taak zonder start-/finishdatums (`undefined.includes('T')`) en
# liet de hele Gantt zwart. Draait de echte renderer over datumloze leaf-/summary-/mijlpaal-rijen: