Een pijl in een diagramlabel breekt de PDF-export niet meer (#1987) #1989

Merged
brenno merged 1 commit from fix/pdf-svg-fontkeuze-1987 into main 2026-09-04 09:39:25 +00:00
Owner

Wat er misging

laag → hoog in een diagram- of grafieklabel liet de PDF-export afbreken met Null check operator used on a null value — dezelfde klasse fout die #1942 dichtzette, opnieuw geopend door #1968.

Twee dingen samen:

1. svgTypesetting koos het symbolen-font zodra Roboto één teken miste. Nagemeten:

tekening gekozen snede
laag → hoog NotoSansMath
Критик высокий Roboto
Критик → высокий NotoSansMath

2. NotoSansMath-subset.ttf heeft geen letters en geen spatie. Zijn laagste codepunt is U+2190. De SVG-lezer van package:pdf zet alle tekst van een tekening met die ene snede, en TtfWriter.withChars doet voor de spatie een onvoorwaardelijke charToGlyphIndexMap[32]!.

Zelfs zonder die worp was de keuze verkeerd: elke letter van laag → hoog in een wiskundefont is een leeg blokje. Vóór #1968 stond er een leesbaar label met één blokje op de plek van de pijl; erna was het hele document weg.

Wat er verandert

De terugvalfonts zijn één geordende lijst. fallbackFont + symbolFont zijn fallbackFonts geworden. De vorige vorm gaf geen plek aan de vraag wélke snede een tekening krijgt, en wordt onhoudbaar bij elk font dat erbij komt.

Inter staat in die lijst, tussen Roboto en het wiskundefont. Die zat al in de app als interfaceletter, dekt → ← ↑ ↓ ↔ ⇒ ✓ ✗ ★ ▪ én alle letters, en kost dus geen byte extra, geen licentieregel en geen SBOM-wijziging.

Een tekening krijgt de eerste snede die haar hélemaal kan zetten, of anders haar bron. Dat lijkt streng — waarom niet de best dekkende snede, en de rest een blokje? Omdat een ontbrekende glyph in TtfWriter.withChars níét betrouwbaar een blokje wordt. Nagemeten met Roboto:

tekst uitkomst
laag ⨁ ∮ hoog blokjes
a ⨁ ∮ worp
Ԁ ∮ blokje
Ԁ ⨁ ∮ worp

Waar die grens ligt hangt af van hoeveel glyphs de subset verderop nog over heeft — niet na te bouwen, en bij de volgende versie van de bibliotheek mogelijk anders. Op zo'n grens valt geen regel te bouwen. Alles-of-de-bron is wél te beredeneren, en het is dezelfde afweging die er voor een onleesbare SVG al stond.

Waarom dit er ongezien doorheen kwam

De exporttests bouwden hun fonts met Roboto alleen, terwijl de app met drie fonts exporteert. De hele klasse fouten die uit de fontkeuze komt was daarmee onbereikbaar voor de test. Ze gebruiken nu dezelfde lijst en dezelfde volgorde als loadPdfFallbackFonts in de schil.

Eén bestaande test legde bovendien het foute gedrag vast: hij toetste wélk font gekozen werd (fonts.fallback[1]) in plaats van of de gekozen snede het label kán zetten. Die is omgeschreven naar de eigenschap.

Regressietests

  • een pijl mét spatie in een diagramlabel breekt de export niet (stond rood — wierp)
  • de pijl wordt gezet en niet gemeld
  • een pijl kiest de snede die het hele label aankan
  • een snede zonder letters wordt nooit gekozen
  • een tekening die geen énkele snede helemaal aankan valt terug op haar bron
  • een teken dat geen enkele snede kent () wordt gemeld
  • de terugvallijst houdt de volgorde die de aanroeper gaf
  • de drie gebundelde fonts zijn te laden

Met eigen ogen bekeken

Een echte export met , , , een gedachtestreepje en krul-apostrofs in kop, alinea, grafiektitel, aslabels en legenda, gerasterd met QuickLook: alles staat er, niets is een blokje, en unsupportedCharacters is leeg.

Poorten

  • make check-secrets groen (gitleaks + trufflehog)
  • make sast groen (semgrep)
  • make check groen behalve test-golden

Die 23 golden-tests falen ook op een schone origin/main — de twee lijsten met faalnamen zijn letterlijk identiek (diff leeg), en het gaat om sub-promille pixelverschillen (0.00%, 28px). Dat is vastgelegd als #1988 en staat los van deze PR.

Bewaker

Geen bestandsformaat, geen opslag, geen nieuwe afhankelijkheid, geen uitgaand verkeer, geen publieke belofte — er komt zelfs geen bestand bij. Bewaker-stap expliciet overgeslagen.

Closes #1987

🤖 Generated with Claude Code

## Wat er misging `laag → hoog` in een diagram- of grafieklabel liet de PDF-export afbreken met `Null check operator used on a null value` — dezelfde klasse fout die #1942 dichtzette, opnieuw geopend door #1968. Twee dingen samen: **1. `svgTypesetting` koos het symbolen-font zodra Roboto één teken miste.** Nagemeten: | tekening | gekozen snede | |---|---| | `laag → hoog` | **NotoSansMath** | | `Критик высокий` | Roboto | | `Критик → высокий` | **NotoSansMath** | **2. `NotoSansMath-subset.ttf` heeft geen letters en geen spatie.** Zijn laagste codepunt is U+2190. De SVG-lezer van `package:pdf` zet *alle* tekst van een tekening met die ene snede, en `TtfWriter.withChars` doet voor de spatie een onvoorwaardelijke `charToGlyphIndexMap[32]!`. Zelfs zonder die worp was de keuze verkeerd: elke letter van `laag → hoog` in een wiskundefont is een leeg blokje. Vóór #1968 stond er een leesbaar label met één blokje op de plek van de pijl; erna was het hele document weg. ## Wat er verandert **De terugvalfonts zijn één geordende lijst.** `fallbackFont` + `symbolFont` zijn `fallbackFonts` geworden. De vorige vorm gaf geen plek aan de vraag wélke snede een tekening krijgt, en wordt onhoudbaar bij elk font dat erbij komt. **Inter staat in die lijst**, tussen Roboto en het wiskundefont. Die zat al in de app als interfaceletter, dekt `→ ← ↑ ↓ ↔ ⇒ ✓ ✗ ★ ▪` én alle letters, en kost dus geen byte extra, geen licentieregel en geen SBOM-wijziging. **Een tekening krijgt de eerste snede die haar hélemaal kan zetten, of anders haar bron.** Dat lijkt streng — waarom niet de best dekkende snede, en de rest een blokje? Omdat een ontbrekende glyph in `TtfWriter.withChars` níét betrouwbaar een blokje wordt. Nagemeten met Roboto: | tekst | uitkomst | |---|---| | `laag ⨁ ∮ hoog` | blokjes | | `a ⨁ ∮` | **worp** | | `Ԁ ∮` | blokje | | `Ԁ ⨁ ∮` | **worp** | Waar die grens ligt hangt af van hoeveel glyphs de subset verderop nog over heeft — niet na te bouwen, en bij de volgende versie van de bibliotheek mogelijk anders. Op zo'n grens valt geen regel te bouwen. Alles-of-de-bron is wél te beredeneren, en het is dezelfde afweging die er voor een onleesbare SVG al stond. ## Waarom dit er ongezien doorheen kwam De exporttests bouwden hun fonts met **Roboto alleen**, terwijl de app met drie fonts exporteert. De hele klasse fouten die uit de fontkeuze komt was daarmee onbereikbaar voor de test. Ze gebruiken nu dezelfde lijst en dezelfde volgorde als `loadPdfFallbackFonts` in de schil. Eén bestaande test legde bovendien het foute gedrag vast: hij toetste *wélk* font gekozen werd (`fonts.fallback[1]`) in plaats van of de gekozen snede het label kán zetten. Die is omgeschreven naar de eigenschap. ## Regressietests - een pijl mét spatie in een diagramlabel breekt de export niet (stond rood — wierp) - de pijl wordt gezet en niet gemeld - een pijl kiest de snede die het hele label aankan - een snede zonder letters wordt nooit gekozen - een tekening die geen énkele snede helemaal aankan valt terug op haar bron - een teken dat geen enkele snede kent (`☑`) wordt gemeld - de terugvallijst houdt de volgorde die de aanroeper gaf - de drie gebundelde fonts zijn te laden ## Met eigen ogen bekeken Een echte export met `→`, `✓`, `≤`, een gedachtestreepje en krul-apostrofs in kop, alinea, grafiektitel, aslabels en legenda, gerasterd met QuickLook: alles staat er, niets is een blokje, en `unsupportedCharacters` is leeg. ## Poorten - [x] `make check-secrets` groen (gitleaks + trufflehog) - [x] `make sast` groen (semgrep) - [x] `make check` groen **behalve** `test-golden` Die 23 golden-tests falen **ook op een schone `origin/main`** — de twee lijsten met faalnamen zijn letterlijk identiek (`diff` leeg), en het gaat om sub-promille pixelverschillen (`0.00%, 28px`). Dat is vastgelegd als #1988 en staat los van deze PR. ## Bewaker Geen bestandsformaat, geen opslag, geen nieuwe afhankelijkheid, geen uitgaand verkeer, geen publieke belofte — er komt zelfs geen bestand bij. Bewaker-stap expliciet overgeslagen. Closes #1987 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(pdf): een pijl in een diagramlabel breekt de export niet meer (#1987)
All checks were successful
scans / scans (pull_request) Successful in 2m31s
static-gate / static-gate (pull_request) Successful in 5m48s
e7c4622ea1
#1968 zette een symbolen-font achter Roboto en liet `svgTypesetting` daarop
overschakelen zodra Roboto één teken miste. Dat font is een subset van Noto Sans
Math die bij U+2190 begint: geen letters, geen spatie. De SVG-lezer van
`package:pdf` zet álle tekst van een tekening met díe ene snede, en
`TtfWriter.withChars` doet voor de spatie een onvoorwaardelijke
`charToGlyphIndexMap[32]!`. `laag → hoog` in een label kostte zo het hele
document — dezelfde klasse fout die #1942 dichtzette.

Drie wijzigingen:

* De terugvalfonts zijn één geordende lijst in plaats van twee benoemde rollen.
  De vorige vorm gaf geen plek aan de vraag wélke snede een tekening krijgt, en
  wordt onhoudbaar bij elk font dat erbij komt.
* Inter staat in die lijst. Die zat al in de app als interfaceletter, dekt
  `→ ← ↔ ⇒ ✓ ✗ ★ ▪` én alle letters, en kost dus geen byte extra.
* Een tekening krijgt de eerste snede die haar hélemaal kan zetten, of anders
  haar bron. Streng, en met reden: een ontbrekende glyph wordt in
  `TtfWriter.withChars` niet betrouwbaar een blokje — `laag ⨁ ∮ hoog` geeft
  blokjes, `a ⨁ ∮` een uitzondering. Waar die grens ligt hangt af van wat de
  glyph-subset verderop nog over heeft; daar valt geen regel op te bouwen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
brenno merged commit 3f85f0a7f1 into main 2026-09-04 09:39:25 +00:00
Sign in to join this conversation.
No description provided.