Technical Article

EndDoc-buggen som tyst inaktiverade teckensnitts-subsetting

Generera en rapport, bädda in ett TrueType-teckensnitt, och utdata öppnas korrekt i alla visningsprogram du provar. Glyferna är rätt, texten är markerbar, filen är giltig. Det enda som är fel är storleken. Ett dokument som använde några dussin latinska tecken bär på hela teckensnittet på 350 KB. Ett dokument som skrev ut ett stycke kinesiska bär på ett CJK-teckensnitt på 14 MB istället för den halva megabyte som det egentligen borde behöva. Inget undantag utlöstes, ingen varning loggades och filen godkändes vid validering. Detta är hur ett felordnat slutförandesteg ser ut utifrån: ingenting misslyckas, och det enda beviset är ett tal som är för stort

Buggen som orsakade detta fanns i HotPDF under en utgivningslinje och har sedan dess åtgärdats. Det är värt att skriva om detta, inte som ett felmeddelande utan som en läxa, eftersom misstagets natur är generell. Alla dokumentmotorer har ett slutförandesteg som muterar objekt precis innan de skrivs, och korrektheten i detta steg beror helt på ordningen av dess steg i förhållande till serialiseringen. Om ett steg hamnar på fel sida av skrivningen gör det ingenting, i all tystnad

Vad teckensnitts-subsetting är tänkt att göra

Ett delmängdsteckensnitt (subset) är den del av en TrueType-fil som ett dokument faktiskt använder. ISO 32000-1 §9.9 beskriver hur ett inbäddat teckensnittsprogram ligger i en ström som refereras av teckensnittsdeskriptorn, och för ett TrueType-program är den strömmen /FontFile2 med en /Length1 som anger det okomprimerade byteantalet. Subsetting skriver om tabellerna glyf och loca så att de endast innehåller de glyfer som dokumentet refererar till, numrerar om glyfidentifierarna och lägger till ett prefix på sex bokstäver som ABCDEF+ till /BaseFont-namnet för att markera teckensnittet som en delmängd, exakt enligt specifikationen. Ett latinskt teckensnitt som reduceras till tio eller femton kilobyte gör skillnaden mellan en slimmad PDF och en som levererar ett helt typsnitt bara för en enda rubrik

Tidpunkten då detta sker spelar roll. Subsetting är inte en transformering som tillämpas på bytes som redan finns på disken. Den redigerar objektgrafen i minnet: den krymper ströminnehållet i /FontFile2, korrigerar /Length1 och skriver om /BaseFont-strängen. Allt detta måste vara på plats när serialiseraren går igenom grafen och genererar bytes. Om ändringarna sker efter att bytes har skrivits uppdaterar de objekt som ingen någonsin kommer att läsa

Symptomet och varför ingenting varnade

Det rapporterade beteendet var fullständiga teckensnitt i utdata utan några diagnosmeddelanden. En användare som registrerade ett Unicode TrueType-teckensnitt och skapade ett normalt dokument upptäckte att det inbäddade teckensnittsobjektet hade samma längd som källfilen .ttf, och att /BaseFont-namnet inte bar på något sexbokstavigt subset-prefix. Utdata krympte aldrig mellan körningar som använde tio glyfer och körningar som använde tiotusen

Frånvaron av fel är den del som gör denna typ av bugg dyrbar. En subsetting-rutin som körs vid fel tidpunkt körs fortfarande. Den går igenom den ackumulerade kodpunktanvändningen, bygger en helt korrekt delmängd och tillämpar den på objektgrafen i minnet. Internt är arbetet utfört och anropet returnerar utan problem. Det enda felet är att objektgrafen den redigerade inte längre är den som skrivs, eftersom skrivaren redan har avslutat. Från anroparens synvinkel skapades och sparades dokumentet utan problem, vilket är precis det intryck ett tyst misslyckande ger

Orsaken var slutförandeordningen

I HotPDF sker stängningsarbetet inuti EndDoc. Subsetting-steget är en intern rutin vid namn BuildAndApplyUnicodeFontSubset. Den läser den uppsättning använda kodpunkter per dokument som sparas i en bitmapp som textgenereringssökvägen fyller i när glyfer visas, mappar varje använd kodpunkt via den cachelagrade kodpunkt-till-glyf-tabellen till en verklig glyfidentifierare och skriver om teckensnittsprogrammet runt denna stängning. När ett Unicode TrueType-teckensnitt registreras ställer genereringssökvägen in en bit i uppsättningen av använda kodpunkter för varje tecken den ritar, så när dokumentet stängs motorn vet exakt vilka glyfer som delmängden måste behålla

Defekten var att BuildAndApplyUnicodeFontSubset anropades efter att SaveToStream eller SaveToFile redan hade serialiserat dokumentet. Subsetter-redigeringarna av /FontFile2, dess korrigerade /Length1 och det sexbokstaviga /BaseFont-prefixet beräknades alla mot en objektgraf som redan hade omvandlats till bytes. Lösningen var en omordning på en enda rad: flytta subset-anropet före serialiseringen, så dass skrivaren genererar det subset-behandlade teckensnittet istället för originalet. Den korrigerade sekvensen kör subsetter-verktyget först och serialiserar efteråt

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;

Med ordningen korrigerad ändras ingenting i anropskoden. Subsetting är aktiverat som standard när ett Unicode TrueType-teckensnitt har registrerats. Du registrerar teckensnittet, påbörjar dokumentet, ritar och avslutar det, och delmängden byggs från de glyfer du använde innan bytes lämnar minnet

Varför ett felplacerat steg utgör en hel kategori

Anledningen till att detta är värt en läxa snarare än en fotnot är att EndDoc genererar en lista med stängningssteg, och vart och ett av dem är känsligt för sin position i förhållande till skrivningen. Teckensnitts-subsetting är ett av dem. PDF/A-utdata kräver en /CIDSet-ström som räknar upp exakt de glyfidentifierare som finns i delmängden, en begränsning som ISO 19005 inför så att en validerare kan bekräfta att det inbäddade programmet matchar vad teckensnittsdeskriptorn anger; den strömmen genereras i samma slutförandefönster och beror på att delmängden har byggts först. PDF/UA-1 kräver, enligt ISO 14289-1 §7.18.3, att varje sida som bär en annotering deklarerar /Tabs med värdet /S, och en intern rutin vid namn EnsurePDFUATabsOnAnnotatedPages stämplar den nyckeln under samma skede. Kontroller av utdatainventering (output intent) körs också där

Samma ordningsfel som inaktiverade subsetting tappade också PDF/UA-tabbnyckeln på annoterade sidor, eftersom det steget låg på samma felaktiga sida om skrivningen. veraPDF och PAC rapporterar en saknad /Tabs /S som en överträdelse av Matterhorn-protokollets kontrollpunkt 21-001. Så ett enda felplacerat anrop ökade inte bara filstorleken; det bröt samtidigt mot ett tillgänglighetskrav, med samma frånvaro av felmeddelanden. Detta är faran med ett slutförandesteg: dess steg delar ett förvillkor, och ett enda ordningsfel kan slå ut flera av dem samtidigt medan alla anrop fortfarande returnerar framgång

Hur ett tyst genereringsfel faktiskt upptäcks

En bugg som inte utlöser något undantag upptäcks inte genom att köra programmet. Den upptäcks genom att inspektera utdata och jämföra dem med vad indata borde ha producerat. För teckensnitts-subsetting är kontrollerna konkreta. Jämför utdatafilens storlek med en grov förväntning: ett dokument som bara berörde en handfull glyfer ska inte ha samma storlek som ett helt typsnitt. Öppna det inbäddade teckensnittsobjektet och läs dess byte-längd; en subset-behandlad /FontFile2 för ett latinskt typsnitt är en liten bråkdel av källfilen. Läs /BaseFont-namnet och bekräfta att det sexbokstaviga prefixet finns där, eftersom dess frånvaro är en direkt signal om att inget subset tillämpades

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;

För PDF/A-utdata är kontrollen ännu skarpare, eftersom en validerare gör arbetet åt dig. Ställ in efterlevnadsnivån och kör resultatet genom veraPDF: en saknad /CIDSet, eller ett subset som inte matchar deskriptorn, rapporteras som en misslyckad klausul snarare än att du själv måste upptäcka det med blotta ögat. Efterlevnadskontrollerna som styr detta slutförandearbete är egenskaper på dokumentet. PDFACompliance tar en sträng som '2B' för PDF/A-2 Level B, och PDFUACompliance är en boolesk variabel som aktiverar kraven för taggad PDF och tabbordning

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;

Den tekniska lärdomen

Två regler framträder ur detta. Den första är att alla slutförandesteg som muterar objekt måste köras innan dessa objekt serialiseras, och stängningsskedet i en dokumentmotor bör läsas som en ordnad pipeline där serialisering är den sista åtgärden, inte en åtgärd bland flera. Den andra är den som kostade mest tid här: för ett genereringssteg är frånvaron av ett fel inte ett bevis på framgång. En rutin som bygger rätt delmängd och tillämpar den på fel, redan skriven graf rapporterar inget fel, eftersom ingenting var fel ur dess eget perspektiv. Verifieringen måste titta på artefakten, inte returkoden. Kontrollera utdatastorleken, läs det inbäddade teckensnittets byte-längd och dess /BaseFont-prefix, och låt veraPDF bedöma PDF/A-utdata där en saknad /CIDSet förvandlar ett tyst underskott till ett namngivet fel

Den producerande sidan av teckensnittshantering, hur teckensnitt registreras och bäddas in för rapportutskrift, beskrivs i vår artikel om teckensnitt och bilder i rapportutskrift. Valideringssidan, där dessa slutförandesteg kontrolleras mot standarderna, beskrivs i genomgången av PDF/A- och PDF/UA-validering. Båda hör ihop med det subsetting- och efterlevnadsarbete som beskrivs här, vilket levereras som en del av HotPDF Component för Delphi och C++Builder tillsammans med de API:er för laddning, redigering, kryptering och signering som beskrivs på andra ställen i denna blogg