Technical Article

De EndDoc-bug die font-subsetting stilletjes uitschakelde

Genereer een rapport, sluit een TrueType-lettertype in, en de uitvoer opent correct in elke viewer die u probeert. De glyphs kloppen, de tekst is selecteerbaar, het bestand is geldig. Het enige wat niet klopt, is de grootte. Een document dat een paar dozijn Latijnse tekens gebruikte, bevat het volledige lettertype van 350 KB. Een document dat een alinea Chinees afdrukte, bevat een CJK-lettertype van 14 MB in plaats van het deel van een halve megabyte dat het eigenlijk nodig zou moeten hebben. Er is geen uitzondering gegenereerd, er is geen waarschuwing gelogd en het bestand is goedgekeurd bij de validatie. Dit is hoe een verkeerd geordende afrondingsstap er van buitenaf uitziet: er mislukt niets en het enige bewijs is een getal dat te groot is.

De bug die dit veroorzaakte, zat gedurende één release-lijn in HotPDF en is inmiddels opgelost. Het is de moeite waard om dit niet als een foutenrapport maar als een les te beschrijven, omdat de vorm van de fout algemeen is. Elke document-engine heeft een afrondingsfase die objecten muteert net voordat ze worden weggeschreven, en de correctheid van die fase hangt volledig af van de volgorde van de stappen ten opzichte van de serialisatie. Plaats één stap aan de verkeerde kant van het schrijven en deze doet geruisloos niets.

Wat font-subsetting hoort te doen

Een subset-lettertype is het deel van een TrueType-bestand dat daadwerkelijk door een document wordt gebruikt. ISO 32000-1 §9.9 beschrijft hoe een ingesloten lettertypeprogramma meeloopt in een stream waarnaar wordt verwezen door de lettertypedescriptor, en voor een TrueType-programma is die stream /FontFile2 met een /Length1 die het ongecomprimeerde aantal bytes aangeeft. Subsetting herschrijft de glyf- en loca-tabellen zodat ze alleen de glyphs bevatten waarnaar het document verwijst, hernummert de glyph-identificaties en voorziet de /BaseFont-naam van een voorvoegsel van zes letters zoals ABCDEF+ om het lettertype als een subset te markeren, precies zoals de specificatie vereist. Een Latijns lettertype dat wordt verkleind tot tien of vijftien kilobyte is het verschil tussen een slanke PDF and een PDF die een volledig lettertype meestuurt voor één enkele kop.

Het moment waarop dit gebeurt is belangrijk. Subsetting is geen transformatie die u toepast op bytes die al op de schijf staan. Het bewerkt de objectgrafiek in het geheugen: het verkleint de inhoud van de /FontFile2-stream, corrigeert /Length1 en herschrijft de /BaseFont-string. Dit moet allemaal op zijn plaats zijn wanneer de serializer door de grafiek loopt en bytes genereert. Als de bewerkingen plaatsvinden nadat de bytes zijn geschreven, worden objecten bijgewerkt die niemand ooit zal lezen.

Het symptoom, en waarom er geen meldingen waren

Het gerapporteerde gedrag was de aanwezigheid van volledige lettertypen in de uitvoer zonder enige diagnose. Een gebruiker die een Unicode TrueType-lettertype registreerde en een normaal document genereerde, merkte dat het ingesloten lettertype-object even lang was als het bron-.ttf-bestand, en dat de /BaseFont-naam geen subset-voorvoegsel van zes letters bevatte. De uitvoer werd nooit kleiner tussen runs die tien glyphs gebruikten en runs die er tienduizend gebruikten.

De afwezigheid van fouten maakt dit type bug hardnekkig. Een subsetting-routine die op het verkeerde moment draait, draait nog steeds. Deze doorloopt het opgebouwde gebruik van codepunten, bouwt een volkomen correcte subset en past deze toe op de objectgrafiek in het geheugen. Intern is het werk gedaan en wordt de aanroep netjes afgerond. Het enige wat niet klopt, is dat de bewerkte objectgrafiek niet meer het object is dat wordt geschreven, omdat de schrijver al klaar was. Vanuit het oogpunt van de aanroeper is het document zonder problemen gegenereerd en opgeslagen, wat precies de indruk is die een stille fout wekt.

De oorzaak was de volgorde van afronding

In HotPDF vindt het afsluitende werk binnen EndDoc. De subsetting-stap is een interne routine genaamd BuildAndApplyUnicodeFontSubset. Deze leest de per-document verzameling van gebruikte codepunten, bijgehouden in een bitmap die door het tekstuitvoerpad wordt gevuld naarmate glyphs worden weergegeven, koppelt elk gebruikt codepunt via de gecachte codepunt-naar-glyph-tabel aan een echte glyph-identificatie en herschrijft het lettertypeprogramma rond die selectie. Wanneer een Unicode TrueType-lettertype is geregistreerd, het uitvoerpad zet een bit in de verzameling gebruikte codepunten voor elk getekend teken, zodat de engine tegen de tijd dat het document sluit precies weet welke glyphs de subset moet behouden.

De fout was dat BuildAndApplyUnicodeFontSubset werd aangeroepen nadat SaveToStream of SaveToFile het document al had geserialiseerd. De bewerkingen van de subsetter aan /FontFile2, de gecorrigeerde /Length1 en het /BaseFont-voorvoegsel van zes letters verhuisden allemaal naar een objectgrafiek die al in bytes was omgezet. De oplossing was een aanpassing van één regel: verplaats de subset-aanroep naar een moment vóór de serialisatie, zodat de schrijver het subset-lettertype genereert in plaats van het origineel. De gecorrigeerde volgorde voert eerst de subsetter uit en serialiseert daarna.

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.RegisterUnicodeTTF('C:\Fonts\NotoSansSC-Regular.ttf');
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Noto Sans SC', [], 12);
    Pdf.CurrentPage.TextOut(72, 760, 0, '报表标题 Report Heading');
    Pdf.EndDoc;                 // subsetting runs here, before the write
    Pdf.SaveToFile('Report.pdf');
  finally
    Pdf.Free;
  end;
end;

Nu de volgorde is gecorrigeerd, verandert er niets aan de aanroepende code. Subsetting is standaard ingeschakeld zodra een Unicode TrueType-lettertype is geregistreerd. U registreert het lettertype, start het document, tekent, en sluit het af. De subset wordt vervolgens opgebouwd uit de glyphs die u hebt gebruikt voordat de bytes uit het geheugen worden geschreven.

Waarom één verkeerde stap een hele categorie is

De reden waarom dit een les waard is in plaats van een voetnoot, is dat EndDoc een lijst met afsluitende stappen doorloopt, en elk daarvan is gevoelig voor de positie ten opzichte van het schrijven. Font-subsetting is er één. PDF/A-uitvoer vereist een /CIDSet-stream die exact de glyph-identificaties in de subset opsomt, een voorwaarde die ISO 19005 stelt zodat een validator kan bevestigen dat het ingesloten programma overeenkomt met wat de lettertypedescriptor claimt. Die stream wordt in hetzelfde afrondingsvenster gegenereerd en is afhankelijk van het feit dat de subset eerst is gebouwd. PDF/UA-1 vereist, conform ISO 14289-1 §7.18.3, dat elke pagina met een annotatie /Tabs declareert met de waarde /S, en een interne routine genaamd EnsurePDFUATabsOnAnnotatedPages schrijft die sleutel weg tijdens dezelfde fase. Controles op de output-intent worden daar ook uitgevoerd.

Dezelfde volgordefout die subsetting uitschakelde, zorgde er ook voor dat de PDF/UA-tabvolgordesleutel op geannoteerde pagina's ontbrak, omdat die stap zich aan dezelfde verkeerde kant van het schrijven bevond. veraPDF en PAC rapporteren een ontbrekende /Tabs /S als een schending van Matterhorn-protocol checkpoint 21-001. Een enkele verkeerd geplaatste aanroep zorgde er dus niet alleen voor dat het bestand groter werd, maar schond tegelijkertijd geruisloos een toegankelijkheidseis, eveneens zonder foutmeldingen. Dat is het risico van een afrondingsfase: de stappen delen een randvoorwaarde, en een enkele volgordefout kan er meerdere tegelijk uitschakelen terwijl elke aanroep toch een succesvolle status retourneert.

Hoe een stille uitvoerfout daadwerkelijk wordt gedetecteerd

Een bug die geen uitzondering genereert, wordt niet ontdekt door het programma uit te voeren. Deze wordt ontdekt door de uitvoer te inspecteren en te vergelijken met wat de invoer had moeten produceren. Voor font-subsetting zijn de controles concreet. Vergelijk de grootte van het uitvoerbestand met een ruwe verwachting: een document dat slechts een handvol glyphs gebruikt, mag niet de grootte hebben van een volledig lettertype. Open het ingesloten lettertype-object en lees de lengte in bytes; een gesubsetteerde /FontFile2 voor een Latijns lettertype is slechts een fractie van het bronbestand. Lees de /BaseFont-naam en controleer of het voorvoegsel van zes letters aanwezig is, aangezien de afwezigheid daarvan een direct signaal is dat er geen subset is toegepast.

var
  Pdf: THotPDF;
  Output: TMemoryStream;
begin
  Output := TMemoryStream.Create;
  try
    Pdf := THotPDF.Create(nil);
    try
      Pdf.RegisterUnicodeTTF('C:\Fonts\DejaVuSans.ttf');
      Pdf.BeginDoc;
      Pdf.CurrentPage.SetFont('DejaVu Sans', [], 11);
      Pdf.CurrentPage.TextOut(72, 760, 0, 'Subset me');
      Pdf.EndDoc;
      Pdf.SaveToStream(Output);
    finally
      Pdf.Free;
    end;
    // A few glyphs from a ~700 KB face must not yield a multi-hundred-KB stream.
    if Output.Size > 100 * 1024 then
      raise Exception.Create('Font subset did not shrink the output');
  finally
    Output.Free;
  end;
end;

Voor PDF/A-uitvoer is de controle nog scherper, omdat een validator het werk voor u doet. Stel het conformiteitsniveau in en controleer het resultaat met veraPDF: een ontbrekende /CIDSet, of een subset die niet overeenkomt met de descriptor, wordt gerapporteerd als een mislukte clausule in plaats van dat u dit handmatig moet ontdekken. De conformiteitsschakelaars die dit afrondingswerk aansturen zijn eigenschappen van het document. PDFACompliance accepteert een string zoals '2B' voor PDF/A-2 Level B, en PDFUACompliance is een boolean die de tagged-PDF- en tabvolgordevereisten inschakelt.

Pdf := THotPDF.Create(nil);
try
  Pdf.PDFACompliance := '2B';     // PDF/A-2 Level B, drives /CIDSet emission
  Pdf.PDFUACompliance := True;    // stamps /Tabs /S on annotated pages
  Pdf.RegisterUnicodeTTF('C:\Fonts\NotoSansSC-Regular.ttf');
  Pdf.BeginDoc;
  Pdf.CurrentPage.SetFont('Noto Sans SC', [], 12);
  Pdf.CurrentPage.TextOut(72, 760, 0, '合规报告');
  Pdf.EndDoc;
  Pdf.SaveToFile('Report_PDFA.pdf');
finally
  Pdf.Free;
end;

De technische les

Hieruit vloeien twee regels voort. De eerste is dat elke afrondingsstap die objecten muteert, moet worden uitgevoerd voordat die objecten worden geserialiseerd. De afsluitfase van een document-engine moet worden gezien als een geordende pijplijn waarin serialisatie de allerlaatste actie is, en niet zomaar een actie tussen de andere. De tweede regel is degene die hier de meeste tijd heeft gekost: voor een uitvoerstap is de afwezigheid van een fout geen bewijs van succes. Een routine die de juiste subset bouwt en past deze toe op de verkeerde, al geschreven grafiek, rapporteert geen fouten, omdat er vanuit het eigen perspectief niets mis was. Validatie moet naar het resulterende bestand kijken, niet naar de retourcode. Controleer de uitvoergrootte, lees de bytelengte van het ingesloten lettertype en het /BaseFont-voorvoegsel, en laat veraPDF de PDF/A-uitvoer beoordelen, waar een ontbrekende /CIDSet een stille tekortkoming verandert in een expliciete fout.

De producentenkant van lettertypeverwerking, hoe lettertypen worden geregistreerd en ingesloten voor rapportuitvoer, wordt behandeld in ons artikel over lettertypen en afbeeldingen in rapportuitvoer. De validatiekant, waar deze afrondingsstappen worden getoetst aan de standaarden, wordt beschreven in de handleiding over PDF/A- en PDF/UA-validatie. Beide sluiten aan bij het subsetting- en conformiteitswerk dat hier wordt beschreven en dat wordt geleverd als onderdeel van de HotPDF Component voor Delphi en C++Builder, naast de laad-, bewerkings-, versleutelings- en ondertekenings-API's die elders op deze blog worden behandeld.