Technisch artikel

Font-subsets op schijf cachen met HotPDF in Delphi

Twee facturen die vanuit hetzelfde sjabloon met hetzelfde TrueType-lettertype zijn gerenderd en alleen in de achternaam van de klant verschillen, laten twee aparte bestanden achter in een font-subsetcache. De achternaam voegt een hoofdletter Q en een kleine z toe die de eerste factuur nooit gebruikte, dus de set behouden glyphs verandert, en daarmee de subset en de cachesleutel. Een persistente subsetcache levert pas wat als het glyph-repertoire van uw documenten herhaalt, en de tellers die de cache blootgeeft vertellen u binnen één batch of dat bij u het geval is

Diagram van de vier font-subset-cache-sleutels in HotPDF: fontinhoud, glyphset, subsetmodus en schemaversie, met twee facturen die door één naam verschillen en in verschillende entries belanden
twee facturen die op één klantnaam verschillen zijn twee cache-entries — de cache verdient zich pas terug wanneer documenten een glyph-repertoire delen

De schijfcache in HotPDF aanzetten

HotPDF houdt de subsetcache uit totdat u er een map aan geeft. Twee properties op THotPDF sturen dat gedrag: FontSubsetCacheFolder benoemt de directory, en FontSubsetCacheMaxBytes begrenst hoeveel hij mag bevatten, met 268435456 bytes (256 MiB) als standaard. De cache is alleen actief terwijl de map niet leeg is en het budget boven nul zit. Bij het toewijzen van de map wordt de directory aangemaakt als die ontbreekt en wordt meteen alles wat er al staat afgetrimd tot het budget

Subsetting is het cachen waard omdat het echt werk is. Een TrueType-subset bouwen betekent de composite-glyph-closure doorlopen, loca en glyf herschrijven, de tabellen die glyphs indexeren herbouwen, en een CID-mapping produceren die de PDF kan adresseren. Voor één document verdwijnt die kostenpost in de ruis. In een rapportserver die duizenden afschriften in een lus rendert met dezelfde drie fonts, worden dezelfde subsets telkens opnieuw gebouwd, en dat is precies de CPU die de cache teruggeeft

var
  Pdf: THotPDF;
  Info: THPDFFontSubsetCacheInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.EnableFontSubsetting := True;
    // ASCII-only pad: de property is een AnsiString
    Pdf.FontSubsetCacheFolder := 'C:\ProgramData\Reports\fontcache';
    Pdf.FontSubsetCacheMaxBytes := 64 * 1024 * 1024;   // 64 MiB in plaats van de standaard 256 MiB
    // ... BeginDoc / tekenwerk / EndDoc per document in de batch ...
    Info := Pdf.GetFontSubsetCacheInfo;
    // LogFmt is uw eigen loggingroutine
    LogFmt('subset cache: %d hits, %d misses, %d bytes in %d files',
      [Info.HitCount, Info.MissCount, Info.CurrentBytes, Info.FileCount]);
  finally
    Pdf.Free;
  end;
end;

De cache zit achter beide subsetpaden: faces die via EnableFontSubsetting worden gesubset, wat TrueType- en OpenType-CFF-fonts dekt, en de face die met RegisterUnicodeTTF is geregistreerd. Verder verandert er niets in de rest van uw code. Het mappad is gedeclareerd als AnsiString, dus een directorynaam met tekens buiten de systeemcodepagina vraagt om problemen; een eenvoudig ASCII-pad onder ProgramData of een servicemap vermijdt de kwestie volledig

Wat er in een cachesleutel gaat

Vier invoergegevens bepalen of een opgeslagen subset herbruikbaar is: de exacte bytes van het bronlettertype, de set glyphs die het document behoudt, de subsetmodus en de cacheformaatversie. Elke sleutel is opgebouwd uit een SHA-256-hash van de fontbytes en een SHA-256-hash van een bitmap van gebruikte glyph-ID's, met het voorvoegsel v1- en het achtervoegsel -s voor een sparse subset of -c voor een compacte. Entries belanden als <key>.hpdfsubset op schijf, zodat een directoryoverzicht in één oogopslag laat zien welke modus uw batch produceert

De fontinhoud hashen in plaats van de bestandsnaam heeft een praktisch gevolg. Arial bijwerken of een nieuwere build van een huisstijllettertype inruilen levert automatisch nieuwe sleutels op, en de oude entries worden simpelweg niet meer geraakt totdat verwijdering ze opruimt. Na een fontupdate hoeft u de cache niet te legen om correcte uitvoer te krijgen. Leegmaken haalt die ruimte alleen eerder terug

De subsetmodus is de invoer die mensen vergeten. CompactFontSubsetting nummert de behouden glyphs opnieuw in een dicht bereik en schrijft een expliciete /CIDToGIDMap weg (ISO 32000-1 §9.7.4.2), en de geregistreerde face krijgt altijd een compacte subset wanneer PDFACompliance is ingesteld. CompressDocument zet compacte subsets bovendien aan voor de duur van het document. Een service die hetzelfde afschrift één keer als gewone PDF en één keer als PDF/A schrijft, vult dus twee sets entries voor identieke glyphs, één eindigend op -s en één op -c. OpenType-CFF-faces volgen altijd het sparse pad

De glyphset bepaalt de hit-rate. Afschriften uit een vast sjabloon, formulieren waarvan de variabele gegevens vooral cijfers zijn, catalogi getrokken uit één productdatabase: die hergebruiken document na document dezelfde glyphs. Een workload waarbij elk document een andere plak uit een CJK-font met tienduizenden glyphs trekt, mist vrijwel altijd, omdat bijna elke document-bitmap nieuw is. Draai één representatieve batch en lees de tellers voordat u beslist welk type workload u heeft

Hoe weet u of de cache iets doet?

GetFontSubsetCacheInfo geeft een THPDFFontSubsetCacheInfo-record met negen velden terug, en HitCount naast MissCount beantwoordt de vraag direct. WriteCount en EvictionCount tonen of entries lang genoeg leven om hergebruikt te worden of worden weggedrukt door een te krap budget. CurrentBytes en FileCount beschrijven de map zoals hij op dit moment is

HotPDF-paneel van de negen GetFontSubsetCacheInfo-tellers, gegroepeerd in hit rate, overleving en mislukkingen, met drie aflezingen die benoemen welke instelling u moet veranderen
de verhouding van HitCount tot MissCount beantwoordt de vraag of de cache iets doet, en de andere tellers zeggen waarom niet

Twee details tellen als u deze tellers in monitoring verwerkt. De zeven eventtellers horen bij de THotPDF-instantie en starten op nul zodra de component wordt aangemaakt, dus een service die per document één component aanmaakt, ziet per aflezing de activiteit van één document. CurrentBytes en FileCount komen daarentegen uit het doorlopen van de map op het moment van de aanroep, waardoor ze elk proces weerspiegelen dat die map gebruikt en elke aanroep een directoryscan kost. Lees het record één keer per batch, aan het eind, en houd de lus per document er vrij van

De laatste drie tellers zijn degenen die een alarm waard zijn. CorruptCount telt entries die bij het laden de validatie niet doorstonden en zijn verwijderd; elke entry telt ook als miss. Een handjevol na een stroomuitval is te verwachten, en een gestage druppel wijst naar de opslag. RejectedCount telt subsets die de cache geweigerd heeft op te slaan, doorgaans omdat één subset groter is dan het hele budget. WriteFailureCount telt schrijfacties die niet voltooid werden, wat in de praktijk bijna altijd een map is waar het serviceaccount niet naar mag schrijven. Geen van de drie onderbreekt de documentgeneratie, dus een cache met een schrijfrechtenprobleem produceert exact dezelfde PDF's als helemaal geen cache. Hij wordt alleen nooit sneller

// SubsetCacheNeedsAttention is een helper die u zelf toevoegt;
// de drempels zijn voorbeelden, stem ze af op uw batchgrootte
function SubsetCacheNeedsAttention(const Info: THPDFFontSubsetCacheInfo;
  out Reason: string): Boolean;
begin
  Reason := '';
  if Info.WriteFailureCount > 0 then
    Reason := 'cache folder is not writable for this account'
  else if Info.RejectedCount > Info.WriteCount then
    Reason := 'subsets exceed FontSubsetCacheMaxBytes, raise the budget'
  else if (Info.EvictionCount > Info.WriteCount div 2) and (Info.HitCount < Info.MissCount) then
    Reason := 'entries are evicted before they are reused'
  else if Info.CorruptCount > 10 then
    Reason := 'repeated damaged entries, check the storage';
  Result := Reason <> '';
end;

Budgetten, verwijdering en de schakelaar die de map leegt

Verwijdering verloopt least-recently-used op basis van de wijzigingstijd van het bestand. Elke geslaagde lezing actualiseert de tijdstempel van de entry, elke schrijfactie wordt gevolgd door een trim, en de oudste bestanden gaan eerst weg tot de map weer binnen het budget past. FontSubsetCacheMaxBytes tijdens runtime verlagen trimt ter plekke in plaats van te wachten op de volgende schrijfactie, dus een service die reageert op een waarschuwing weinig schijfruimte kan de cache verkleinen op het moment dat hij daartoe besluit

Die directheid heeft een scherpe rand. Het budget op nul zetten terwijl er een map is ingesteld, trimt tot nul en verwijdert elk .hpdfsubset-bestand in die map. FontSubsetCacheFolder op een lege tekenreeks zetten schakelt de cache daarentegen uit en laat de bestanden precies waar ze staan. Beide schakelaars zetten caching uit voor het volgende document; slechts één van hen houdt een week aan warme entries over. Voor probleemoplossing: maak de map-property leeg en laat het budget met rust

// Caching pauzeren voor een diagnostische run, elke opgeslagen entry behouden
Pdf.FontSubsetCacheFolder := '';

// Gevaar: met een map ingesteld trimt een budget van nul de map leeg
// Pdf.FontSubsetCacheMaxBytes := 0;

// Bewuste reset: verwijdert *.hpdfsubset en achtergebleven temp-bestanden,
// de eventtellers behouden hun waarden
Pdf.FontSubsetCacheFolder := 'C:\ProgramData\Reports\fontcache';
Pdf.ClearFontSubsetCache;

Een diagnostische run met de cache op pauze is de schoonste manier om hem uit te sluiten. De subsettag van zes letters in /BaseFont is afgeleid uit de behouden glyphset, dus dezelfde glyphs leveren dezelfde tag op met of zonder cache, en de ingesloten fontprogramma's zouden moeten overeenkomen. Hashes van hele bestanden blijven verschillen door aanmaakdata en de document-ID, dus vergelijk de fontstreams, niet de bestanden

Wat gebeurt er met een beschadigde of half geschreven entry?

Een beschadigde entry wordt verwijderd en de subset wordt normaal gebouwd, voordat ook maar iets uit die entry een PDF-stream bereikt. Bij het laden controleert HotPDF de bestandshandtekening, de formaatversie, de subsetmodusvlag, het glyphaantal tegenover het bronlettertype, de opgegeven lengtes tegenover de werkelijke bestandsgrootte, en de sfnt-header van het opgeslagen fontprogramma. Elke afwijking gooit de entry weg, verhoogt CorruptCount en valt terug op subsetting in het geheugen. Een kapot fontprogramma in de PDF van een klant zou weken later opduiken in een viewer op de machine van iemand anders, ver van zijn oorzaak, en dat is precies het falen dat deze validatie moet voorkomen

Stroomdiagram van de afhandeling van beschadigde cache-entries in HotPDF: valideren vóór gebruik, verwerpen en verwijderen vóór elke PDF-stream, terugvallen op in-memory subsetting
beschadigde entries falen open — het document wordt nog steeds geproduceerd, en PDF/A-uitvoer houdt de cache aan omdat entries de CID-remapping behouden

Half geschreven entries verschijnen helemaal niet. Een schrijfactie gaat eerst naar een tijdelijk bestand waarvan de naam het proces-ID en de componentinstantie draagt, en wordt daarna met één rename met replace-en-flush over de definitieve naam gezet. Een crash halverwege laat een achtergebleven bestand met de extensie .tmp- achter, die ClearFontSubsetCache opveegt, en de echte entry is ofwel de complete oude versie ofwel de complete nieuwe. Compacte entries slaan bovendien de oude-naar-nieuwe glyphmap op naast het fontprogramma, dus een gecachte compacte subset herbouwt dezelfde /CIDToGIDMap en PDF/A-uitvoer kan de cache gebruiken zonder hem te omzeilen

Waar hoort de map thuis?

Drie voorwaarden beslissen dat: het serviceaccount moet er kunnen schrijven, de map hoort op lokale opslag, en geen enkele uitrolstap mag hem wissen. Een netwerkshare maakt van elke opzoekactie een round-trip en legt de rename-semantiek in de handen van de bestandsserver. Een map in de applicatiedirectory die het installatieprogramma opnieuw aanmaakt, betekent dat de cache na elke update koud start, wat precies op de dag dat u het nauwkeurigst naar prestaties kijkt stilletjes het voordeel wegneemt

Meerdere componenten en meerdere processen kunnen één lokale map delen. Tijdelijke namen botsen nooit, de definitieve rename is atomair, en een verloren race kost niets ergers dan een miss of een schrijfactie die bij het volgende document opnieuw moet. Delen is de betere standaard op één machine, want elke instantie warmt de cache voor de anderen. Het enige zichtbare neveneffect is dat de tellers van elke instantie alleen zijn eigen verkeer beschrijven, terwijl CurrentBytes en FileCount die van iedereen beschrijven, en nu en dan een tik van WriteFailureCount kan komen van twee processen die dezelfde entry tegelijk vervangen. Een alleen-lezen map levert nog steeds bestaande entries; alleen hun tijdstempels verversen of nieuwe toevoegen kan niet

Wanneer de cache de verkeerde hefboom is

De cache haalt herhaald werk weg. Voor het eerste document van een batch doet hij niets, en voor workloads waarvan de glyphsets nooit herhalen weinig. Als uw uitvoer wordt gedomineerd door één groot CJK-font over onvoorspelbare tekst, kijk dan welke glyphs in elke subset belanden en waarom, behandeld in de notities over font-subsetclosure en shaping-glyphs. Als bestandsgrootte in plaats van CPU het doel is, legt het artikel over CompressDocument en compacte font-subsets uit wat de compacte modus in het ingesloten lettertype verandert. Als een batch om redenen los van fonts traag blijkt, laat de doorloop van rapportuitvoer met fonts en afbeeldingen zien waar de rest van de tijd meestal naartoe gaat, en het verslag over de EndDoc-font-subsetvolgorde behandelt wanneer subsets tijdens de documentfinalisatie worden gebouwd

Omdat de subsetcache in de component zit, pikt een Delphi- of C++Builder-rapportserver hem mee door een mappad en een budget in te stellen, zonder extra service om uit te rollen of te monitoren. Font-insluiting, subsetting en de rest van de PDF-generatiefuncties staan vermeld op de HotPDF Delphi-componentpagina