PDF-export: een tabelkopregel kan verweesd onderaan een pagina staan #1790

Closed
opened 2026-08-26 11:48:33 +00:00 by brenno · 5 comments
Owner

Wat is het probleem?

Een tabelkopregel kan onderaan een pagina worden geplaatst zonder dat er ook maar één inhoudsrij achteraan past. De kopregel herhaalt vervolgens bovenaan de volgende pagina. Het resultaat is een losse gele balk onder aan het blad die niets aankondigt.

#1758 loste dit op voor sub-hoofdstukken (##/###/####) met keep-with-next. Een tabelkop heeft dezelfde behoefte en kreeg die niet.

Hoe te reproduceren

  1. Maak een document waarin een tabel zo begint dat de kopregel nog net op een blad past en de eerste inhoudsrij niet.
  2. Exporteer naar PDF.

Verwacht gedrag

Een tabelkopregel houdt ten minste één inhoudsrij bij zich, net als een sub-hoofdstuk sinds #1758. Past dat niet meer op het blad, dan schuift de kop mee naar de volgende pagina.

Werkelijk gedrag

Waargenomen in OciDeck 0.4.10, in alle drie de RWM-rapporten:

  • Incidentrapport-RWM-aanval-op-de-externe-werkomgeving-RDWebRD-Gateway-volledig.pdf: pagina 1 (kernbeoordelingstabel), pagina 20 (§7.1 accountvergrendelingen), pagina 21 (bron-IP-tabel) — telkens alleen de kopregel onderaan;
  • Beoordeling-van-de-beheersing-van-de-gehoste-technische-omgeving-van-RWM-volledig.pdf: pagina 12, §9 Bronnen — de kop staat onderaan, de tabel begint op 13;
  • Incidentrapport-RWM-onjuiste-toegangsrechten-op-de-Q-schijf-volledig.pdf: pagina 11 — kop plus één rij (R-01), de rest op 12.

Dat laatste geval laat een tweede kant zien: één enkele rij achterblijven is óók lelijk. Twee rijen bij de kop houden zou hier beter zijn.

Richting voor een oplossing

Dezelfde keep-with-next-machinerie als #1758 toepassen op de kopregel van een tabel, met een drempel van ten minste één en bij voorkeur twee inhoudsrijen. Zie lib/services/pdf/document_pdf_widgets.dart en de bestaande toets in test/pdf/document_pdf_keep_with_next_test.dart.

## Wat is het probleem? Een tabelkopregel kan onderaan een pagina worden geplaatst zonder dat er ook maar één inhoudsrij achteraan past. De kopregel herhaalt vervolgens bovenaan de volgende pagina. Het resultaat is een losse gele balk onder aan het blad die niets aankondigt. #1758 loste dit op voor sub-hoofdstukken (`##`/`###`/`####`) met keep-with-next. Een tabelkop heeft dezelfde behoefte en kreeg die niet. ## Hoe te reproduceren 1. Maak een document waarin een tabel zo begint dat de kopregel nog net op een blad past en de eerste inhoudsrij niet. 2. Exporteer naar PDF. ## Verwacht gedrag Een tabelkopregel houdt ten minste één inhoudsrij bij zich, net als een sub-hoofdstuk sinds #1758. Past dat niet meer op het blad, dan schuift de kop mee naar de volgende pagina. ## Werkelijk gedrag Waargenomen in OciDeck 0.4.10, in alle drie de RWM-rapporten: - `Incidentrapport-RWM-aanval-op-de-externe-werkomgeving-RDWebRD-Gateway-volledig.pdf`: pagina 1 (kernbeoordelingstabel), pagina 20 (§7.1 accountvergrendelingen), pagina 21 (bron-IP-tabel) — telkens alleen de kopregel onderaan; - `Beoordeling-van-de-beheersing-van-de-gehoste-technische-omgeving-van-RWM-volledig.pdf`: pagina 12, §9 Bronnen — de kop staat onderaan, de tabel begint op 13; - `Incidentrapport-RWM-onjuiste-toegangsrechten-op-de-Q-schijf-volledig.pdf`: pagina 11 — kop plus één rij (R-01), de rest op 12. Dat laatste geval laat een tweede kant zien: één enkele rij achterblijven is óók lelijk. Twee rijen bij de kop houden zou hier beter zijn. ## Richting voor een oplossing Dezelfde keep-with-next-machinerie als #1758 toepassen op de kopregel van een tabel, met een drempel van ten minste één en bij voorkeur twee inhoudsrijen. Zie `lib/services/pdf/document_pdf_widgets.dart` en de bestaande toets in `test/pdf/document_pdf_keep_with_next_test.dart`.
Author
Owner

Opgepakt. Tak fix/pdf-tabelkop-1790. Verwacht te raken: hetzelfde keep-with-next-pad dat #1758 kreeg, nu voor de kopregel van een tabel; lib/services/pdf/document_pdf_widgets.dart en test/pdf/document_pdf_keep_with_next_test.dart.

Opgepakt. Tak `fix/pdf-tabelkop-1790`. Verwacht te raken: hetzelfde keep-with-next-pad dat #1758 kreeg, nu voor de kopregel van een tabel; `lib/services/pdf/document_pdf_widgets.dart` en `test/pdf/document_pdf_keep_with_next_test.dart`.
Author
Owner

Deels opgelost in PR #1795.

Wat weg is. Een tabel die op één blad past blijft nu heel, zodat zijn kopregel niet meer alleen onderaan kan achterblijven. Dat dekt de korte tabellen die een rapport vullen — in het RDWeb-rapport §7.1 en de bron-IP-tabel, in het Q-schijf-rapport het aanbevelingenregister.

Wat blijft. Een tabel die over meerdere bladen loopt houdt het euvel: §9 Bronnen van de CBW-notitie en de kernbeoordelingstabel op pagina 1 van het RDWeb-rapport.

Waarom niet verder. pw.Table kent geen instelling die voorkomt dat er alleen herhaalrijen op een blad landen — Table.layout plaatst rijen tot er één niet meer past en zet lastLine daarop, ook als dat alleen de kopregel is. MultiPage kan een spannende widget evenmin vragen zich te verplaatsen.

Een volledige reparatie vraagt een eigen SpanningWidget die de pw.Table omhult, na de opmaak vaststelt dat de geplaatste hoogte niet boven die van de kopregel uitkomt, en zich dan met applyContext terugtrekt zodat MultiPage een nieuw blad begint. Dat is te bouwen — saveContext/applyContext zijn publiek — maar het draagt een reëel risico op een oneindige lus: trekt de widget zich óók terug op een vers blad waar de tabel sowieso niet past, dan blijft MultiPage bladen openen. Een betrouwbare "sta ik al bovenaan een leeg blad"-toets is er niet.

Dat risico weegt niet op tegen de winst zolang de korte tabellen gedekt zijn. Wie dit oppakt: begin bij de lus in Table.layout rond regel 470 van package:pdf 3.13.0 — een upstream-patch die weigert te breken op alleen repeat-rijen is waarschijnlijk de eerlijkere plek.

Deels opgelost in PR #1795. **Wat weg is.** Een tabel die op één blad past blijft nu heel, zodat zijn kopregel niet meer alleen onderaan kan achterblijven. Dat dekt de korte tabellen die een rapport vullen — in het RDWeb-rapport §7.1 en de bron-IP-tabel, in het Q-schijf-rapport het aanbevelingenregister. **Wat blijft.** Een tabel die over meerdere bladen loopt houdt het euvel: §9 Bronnen van de CBW-notitie en de kernbeoordelingstabel op pagina 1 van het RDWeb-rapport. **Waarom niet verder.** `pw.Table` kent geen instelling die voorkomt dat er alleen herhaalrijen op een blad landen — `Table.layout` plaatst rijen tot er één niet meer past en zet `lastLine` daarop, ook als dat alleen de kopregel is. `MultiPage` kan een spannende widget evenmin vragen zich te verplaatsen. Een volledige reparatie vraagt een eigen `SpanningWidget` die de `pw.Table` omhult, na de opmaak vaststelt dat de geplaatste hoogte niet boven die van de kopregel uitkomt, en zich dan met `applyContext` terugtrekt zodat `MultiPage` een nieuw blad begint. Dat is te bouwen — `saveContext`/`applyContext` zijn publiek — maar het draagt een reëel risico op een oneindige lus: trekt de widget zich óók terug op een vers blad waar de tabel sowieso niet past, dan blijft `MultiPage` bladen openen. Een betrouwbare "sta ik al bovenaan een leeg blad"-toets is er niet. Dat risico weegt niet op tegen de winst zolang de korte tabellen gedekt zijn. Wie dit oppakt: begin bij de lus in `Table.layout` rond regel 470 van `package:pdf` 3.13.0 — een upstream-patch die weigert te breken op alleen `repeat`-rijen is waarschijnlijk de eerlijkere plek.
Author
Owner

Opnieuw opgepakt en nu tot de bodem uitgezocht. De uitkomst: het resterende deel is niet in OciDeck te repareren. Hieronder waarom, zodat niemand deze weg nog eens hoeft af te lopen.

Het mechanisme, exact

package:pdf 3.13.0, lib/src/widgets/table.dart, de plaatsingslus rond regel 470:

if (totalHeight + lineHeight > constraints.maxHeight) {
  index--;
  break;
}
...
_context.lastLine = index;

Rijen vóór _context.firstLine worden overgeslagen tenzij row.repeat. Past onderaan een blad de kopregel nog wel en de eerste inhoudsrij niet, dan wordt lastLine = 1 en tekent hij de kop daar alleen. Op het volgende blad staat firstLine = 1 en herhaalt de kop zich boven de inhoud. Er is geen instelling die dat voorkomt: Table kent alleen children, border, defaultVerticalAlignment, columnWidths, defaultColumnWidth en tableWidth.

Waarom de voor de hand liggende routes doodlopen

  • Een eigen SpanningWidget eromheen die vaststelt dat er alleen herhaalrijen geplaatst zijn en zich terugtrekt. saveContext/applyContext zijn publiek, dus dat is bouwbaar — maar MultiPage biedt geen "ik plaatste niets, ga door naar het volgende blad"-route. In de lus (multi_page.dart regel 376) is de spanningstak alleen bereikbaar als de widget te hoog is; een widget die nul hoogte meldt valt in de normale tak, wordt aan het blad toegevoegd en krijgt _index++ — de resterende rijen zijn dan weg. Om in de spanningstak te komen moet je een hoogte melden die niet klopt, en dat is precies het soort truc dat bij een volgende upstream-versie stilletjes breekt.
  • De niet-vorderingsbewaking helpt niet. sameCount++ > maxPages staat in een assert (regel 294) en bestaat dus alleen in debug. Rekent zo'n proxy zich mis in een release-build, dan blijft MultiPage bladen openen — een vastloper bij de gebruiker, voor een gele balk.
  • De tabel in stukken knippen die elk op een blad passen, elk met een eigen kopregel, vermijdt het spannen helemaal. Maar de knipgrens komt uit een hoogteschatting: te ruim en elk blad eindigt met een gat, te krap en een Inseparable die hoger is dan een blad slaat de export stuk. Rafelige bladranden bij élke lange tabel is een slechtere ruil dan een enkele verweesde kop.
  • De kop niet laten herhalen haalt de wees weg en zet er een groter probleem voor terug: op blad twee weet de lezer niet meer welke kolom wat is.

Wat er wél gebeurd is

Een tabel die op één blad past blijft sinds aa6d43bf2 heel en kan zijn kopregel dus niet meer alleen achterlaten. Dat dekt de korte tabellen die een rapport vullen. Een tabel die over meerdere bladen loopt houdt het euvel.

Waar het thuishoort

Upstream. De eerlijke reparatie is dat Table.layout weigert te breken op een positie waar alleen repeat-rijen geplaatst zijn — vier regels in de lus hierboven. Wie dat oppakt: DavBfr/dart_pdf, lib/src/widgets/table.dart.

Ik sluit dit issue omdat OciDeck's aandeel af is en het restant een afhankelijkheid betreft die we niet vendoren voor een cosmetisch defect. Komt er een upstream-versie met een oplossing, dan is er niets meer te doen dan bumpen.

Opnieuw opgepakt en nu tot de bodem uitgezocht. De uitkomst: **het resterende deel is niet in OciDeck te repareren.** Hieronder waarom, zodat niemand deze weg nog eens hoeft af te lopen. ## Het mechanisme, exact `package:pdf` 3.13.0, `lib/src/widgets/table.dart`, de plaatsingslus rond regel 470: ```dart if (totalHeight + lineHeight > constraints.maxHeight) { index--; break; } ... _context.lastLine = index; ``` Rijen vóór `_context.firstLine` worden overgeslagen **tenzij** `row.repeat`. Past onderaan een blad de kopregel nog wel en de eerste inhoudsrij niet, dan wordt `lastLine = 1` en tekent hij de kop daar alleen. Op het volgende blad staat `firstLine = 1` en herhaalt de kop zich boven de inhoud. Er is geen instelling die dat voorkomt: `Table` kent alleen `children`, `border`, `defaultVerticalAlignment`, `columnWidths`, `defaultColumnWidth` en `tableWidth`. ## Waarom de voor de hand liggende routes doodlopen - **Een eigen `SpanningWidget` eromheen** die vaststelt dat er alleen herhaalrijen geplaatst zijn en zich terugtrekt. `saveContext`/`applyContext` zijn publiek, dus dat is bouwbaar — maar `MultiPage` biedt geen "ik plaatste niets, ga door naar het volgende blad"-route. In de lus (`multi_page.dart` regel 376) is de spanningstak alleen bereikbaar als de widget te *hoog* is; een widget die nul hoogte meldt valt in de normale tak, wordt aan het blad toegevoegd en krijgt `_index++` — de resterende rijen zijn dan weg. Om in de spanningstak te komen moet je een hoogte melden die niet klopt, en dat is precies het soort truc dat bij een volgende upstream-versie stilletjes breekt. - **De niet-vorderingsbewaking helpt niet.** `sameCount++ > maxPages` staat in een `assert` (regel 294) en bestaat dus alleen in debug. Rekent zo'n proxy zich mis in een release-build, dan blijft `MultiPage` bladen openen — een vastloper bij de gebruiker, voor een gele balk. - **De tabel in stukken knippen** die elk op een blad passen, elk met een eigen kopregel, vermijdt het spannen helemaal. Maar de knipgrens komt uit een hoogteschatting: te ruim en elk blad eindigt met een gat, te krap en een `Inseparable` die hoger is dan een blad slaat de export stuk. Rafelige bladranden bij élke lange tabel is een slechtere ruil dan een enkele verweesde kop. - **De kop niet laten herhalen** haalt de wees weg en zet er een groter probleem voor terug: op blad twee weet de lezer niet meer welke kolom wat is. ## Wat er wél gebeurd is Een tabel die op één blad past blijft sinds `aa6d43bf2` heel en kan zijn kopregel dus niet meer alleen achterlaten. Dat dekt de korte tabellen die een rapport vullen. Een tabel die over meerdere bladen loopt houdt het euvel. ## Waar het thuishoort Upstream. De eerlijke reparatie is dat `Table.layout` weigert te breken op een positie waar alleen `repeat`-rijen geplaatst zijn — vier regels in de lus hierboven. Wie dat oppakt: `DavBfr/dart_pdf`, `lib/src/widgets/table.dart`. Ik sluit dit issue omdat OciDeck's aandeel af is en het restant een afhankelijkheid betreft die we niet vendoren voor een cosmetisch defect. Komt er een upstream-versie met een oplossing, dan is er niets meer te doen dan bumpen.
Author
Owner

Heropend. Mijn afsluiting hierboven was te vroeg en de redenering had een gat.

Ik schreef dat MultiPage geen route heeft waarlangs een spannende widget zich kan terugtrekken. Dat klopt niet. De eerste layout-aanroep in de lus gebruikt de volle paginabeperking, niet de resterende ruimte — een tabel die langer is dan een blad valt daardoor altijd in de spanningstak (multi_page.dart regel 376). Pas dáár wordt hij opnieuw opgemaakt met localConstraints, precies de ruimte die over is. Dat is een aangrijpingspunt.

Mijn tweede argument — kans op een oneindige lus, en de bewaking zit in een assert — geldt alleen voor de naïeve variant die zich altijd terugtrekt. Een omhullende widget die hoogstens één keer achtereen uitwijkt en daarna accepteert wat er past, kan per constructie niet lussen. Die bewaking heb ik weggeredeneerd in plaats van hem te ontwerpen.

Ik pak hem alsnog op.

Heropend. Mijn afsluiting hierboven was te vroeg en de redenering had een gat. Ik schreef dat `MultiPage` geen route heeft waarlangs een spannende widget zich kan terugtrekken. Dat klopt niet. De eerste `layout`-aanroep in de lus gebruikt de **volle** paginabeperking, niet de resterende ruimte — een tabel die langer is dan een blad valt daardoor altijd in de spanningstak (`multi_page.dart` regel 376). Pas dáár wordt hij opnieuw opgemaakt met `localConstraints`, precies de ruimte die over is. Dat is een aangrijpingspunt. Mijn tweede argument — kans op een oneindige lus, en de bewaking zit in een `assert` — geldt alleen voor de naïeve variant die zich altijd terugtrekt. Een omhullende widget die hoogstens één keer achtereen uitwijkt en daarna accepteert wat er past, kan per constructie niet lussen. Die bewaking heb ik weggeredeneerd in plaats van hem te ontwerpen. Ik pak hem alsnog op.
Author
Owner

Opgelost in 4e83b19ac (PR #1799).

OrphanSafeTable stelt in de spanningstak vast dat er alleen herhaalrijen geplaatst zijn en zet lastLine op nul — paint() valt daar meteen op terug, dus dat blad blijft leeg en de opmaak gaat verder op het volgende. Twee grendels houden het eindig: uitwijken mag alleen wanneer de aangeboden hoogte merkbaar kleiner is dan de bladhoogte, en nooit twee keer op dezelfde leesregel.

De valkuil die een ronde kostte: MultiPage kloont de context vóór de eerste opmaakaanroep en zet hem vóór de tweede terug. De bewaarde leesregel mag dus pas losgelaten worden wanneer een opmaak werkelijk iets plaatst — anders begint de tabel bij die tweede aanroep weer bij rij nul en herhaalt hij alles. Dat is precies waarom de regressietoets niet alleen op de verweesde kop let maar óók eist dat alle veertig rijen exact één keer voorkomen; die tweede assertie ving het.

Mijn eerdere afsluiting was fout op twee punten, en beide waren te voorkomen geweest: ik nam aan dat de eerste opmaakaanroep de resterende ruimte gebruikt (het is de volle bladhoogte), en ik redeneerde het lusrisico weg in plaats van er een grendel voor te ontwerpen.

Opgelost in 4e83b19ac (PR #1799). `OrphanSafeTable` stelt in de spanningstak vast dat er alleen herhaalrijen geplaatst zijn en zet `lastLine` op nul — `paint()` valt daar meteen op terug, dus dat blad blijft leeg en de opmaak gaat verder op het volgende. Twee grendels houden het eindig: uitwijken mag alleen wanneer de aangeboden hoogte merkbaar kleiner is dan de bladhoogte, en nooit twee keer op dezelfde leesregel. De valkuil die een ronde kostte: `MultiPage` kloont de context vóór de eerste opmaakaanroep en zet hem vóór de tweede terug. De bewaarde leesregel mag dus pas losgelaten worden wanneer een opmaak werkelijk iets plaatst — anders begint de tabel bij die tweede aanroep weer bij rij nul en herhaalt hij alles. Dat is precies waarom de regressietoets niet alleen op de verweesde kop let maar óók eist dat alle veertig rijen exact één keer voorkomen; die tweede assertie ving het. Mijn eerdere afsluiting was fout op twee punten, en beide waren te voorkomen geweest: ik nam aan dat de eerste opmaakaanroep de resterende ruimte gebruikt (het is de volle bladhoogte), en ik redeneerde het lusrisico weg in plaats van er een grendel voor te ontwerpen.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
LibreKAT/Ocideck#1790
No description provided.