Teknisk artikel

PDF-maskering på operatornivå i Delphi med PDFiumPas

Någon målar en svart ruta över ett namn, plattar inte ut något, levererar filen, och granskaren markerar rektangeln och klistrar in namnet i ett e-brev. PDFiumPas svarar på det med maskering på operatornivå: SaveAsRedacted tar bort endast de Unicode-skalärer vars teckenrutor rör en maskeringsrektangel, bygger om de överlevande från det ursprungliga typsnittet, storleken, matrisen, renderingsläget och färgen, och beskär axelriktade banor och bilder istället för att släppa dem helt

Varför en målad rektangel inte är en maskering

En ritoperation som lagts ovanpå en innehållsström döljer ingenting, eftersom de textvisande operatorerna under den fortfarande finns i strömmen och fortfarande mappar till kodpunkter. ISO 32000-1 §9.4 definierar ett textobjekt som en sekvens av positionerings- och visningsoperatorer inuti BT och ET; en fylld rektangel ritad efteråt är helt enkelt ännu en operator i samma ström. Extraktionen går igenom operatorerna, inte bildpunkterna, så den täckta strängen kommer tillbaka intakt. Verklig maskering måste ta bort operanden, inte skymma utdatan

Den uppenbara säkra implementationen är brutal: hitta varje sidobjekt vars omslutande ruta skär en maskeringsrektangel och ta bort hela objektet. Det är vad tidigare PDFiumPas-utgåvor gjorde, och det är korrekt men dyrt. Ett enda Tj kan bära en hel tabellrad, så att svärta ut ett kontonummer tog datumet, beskrivningen och beloppet med sig. En rektangulär fyllning som råkade vara ett tabellband i full bredd försvann tvärs över hela sidan. En fakturalogotyp försvann eftersom maskeringen klippte ett av dess hörn. Version 3.101.0 flyttar beslutet en nivå ned, från sidobjektet till operanden

Vad tar maskering på operatornivå faktiskt bort?

PDFiumPas tar bort Unicode-skalärer, inte textobjekt. Under SaveAsRedacted bygger komponenten en mappning från tecken till sidobjekt ur den inlästa textsidan, och för varje tecken som ägs av objektet under prövning läser den teckenrutan och skär den rutan mot varje maskeringsrektangel. Tecken som rör en rektangel märks för borttagning; resten märks som överlevande. Om ingenting skär lämnas objektet helt i fred. Om varje tecken skär tas objektet bort helt, precis som förut. Bara det blandade fallet utlöser en uppdelning

PDFiumPas maskering på operatornivå jämförd med borttagning av hela objekt i Delphi: den gamla vägen släpper ett helt textobjekt när ett kontonummer täcks, medan uppdelningsvägen bara tar bort de skärande tecknen och avger varje överlevande som ett eget textobjekt
Bara det blandade fallet utlöser en uppdelning: inget som skär lämnar objektet i fred, allt som skär tar bort det helt

Varje överlevande avges sedan som ett eget textobjekt byggt av det ursprungliga typsnittshandtaget, den ursprungliga teckenstorleken, textmatrisen per tecken, det ursprungliga textrenderingsläget, samt föräldraobjektets fyll- och strecktillstånd inklusive streckbredd, linjefog, linjeände och streckmönster. Att återanvända typsnittshandtaget istället för att lösa upp ett nytt är vad som håller glyferna metriskt identiska, och att återanvända matrisen per tecken är vad som håller kerning och ordmellanrum på plats utan att layouten körs om. Kostnaden är objektantalet: ett behållet tecken blir ett textobjekt, vilket är varför TPdfRedactionOptions.MaxSplitObjects finns som ett hårt tak på genererade fragment

procedure RedactDocument(const SourcePdf, TargetPdf: string);
var
  Pdf: TPdf;
  Options: TPdfRedactionOptions;
  Report: TPdfRedactionReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := SourcePdf;   // filen bär redan /Redact-annoteringar
    Pdf.Active := True;

    Options := TPdfRedactionOptions.Default;
    Options.PreservePartialObjects := True;    // uppdelning på operatornivå (standard)
    Options.RemoveIntersectingAnnotations := True;
    Options.MaxSplitObjects := 20000;          // tak på genererade fragment

    if not Pdf.SaveAsRedacted(TargetPdf, Options, Report) then
      raise Exception.Create(Report.ErrorMessage);   // fall stängt, leverera inte
  finally
    Pdf.Free;
  end;
end;

Rektanglar beskärs, roterad geometri gör det inte

Banor delas bara när PDFiumPas kan bevisa att banan är en axelriktad rektangel. Beviset är medvetet snävt: objektmatrisen måste ha båda skjuvtermerna under 0,0001, banan måste bestå av fyra till sex segment som börjar med ett MOVETO och fortsätter med enbart LINETO, och de transformerade punkterna måste landa på alla fyra hörnen av objektets gränser inom en tolerans på 0,01. En bana som klarar den kontrollen reduceras genom successiv rektangelsubtraktion, där varje maskeringsrektangel skär ut de överlevande i remsor till vänster, till höger, under och över, och varje resulterande remsa återskapas med det ursprungliga fyllningsläget, streckflaggan och måltillståndet. Kurvor, trianglar, klippta former och allt roterat klarar inte kontrollen och hela objektet tas bort

Bilder följer ISO 32000-1 §8.9, där bildsamplen upptar enhetskvadraten mappad genom den aktuella transformationsmatrisen. PDFiumPas inverterar den mappningen för att förvandla varje överlevande fragment i sidrymd tillbaka till normaliserade bildkoordinater, klämmer dem till enhetsintervallet, och konverterar sedan till bildpunktsindex genom att avrunda inåt: vänster och övre kanten går genom Ceil, höger och nedre genom Floor. Den riktningen spelar roll. Att avrunda utåt skulle låta en delvis kolumn av källbildpunkter från den maskerade sidan överleva vid fragmentkanten. De heltaliga bildpunktsgränserna konverteras sedan tillbaka till normaliserade koordinater och används för att härleda fragmentmatrisen, så att den beskurna bitmappen landar exakt på den bildpunktsgräns den skars vid. Själva beskärningen är en radkopiering med hänsyn till radbredden över formaten Gray, BGR, BGRx och BGRA. Liksom med banor tas en roterad eller skjuvad bild, eller en vars matris har en degenererad skalterm, bort i sin helhet

Så beskär PDFiumPas en delvis maskerad bild i Delphi: det överlevande fragmentet i sidrymd mappas tillbaka genom den inverterade CTM:en till normaliserade bildkoordinater, kläms till enhetsintervallet och avrundas inåt så att ingen maskerad bildpunktskolumn överlever
Ceil till vänster och överst, Floor till höger och nederst, så att snittet landar på en hel bildpunktsgräns
// Efter ett lyckat anrop till SaveAsRedacted
Writeln(Format('applied %d redaction(s) on %d page(s)',
  [Report.RedactionCount, Report.RedactedPageCount]));
Writeln(Format('scanned %d object(s), removed %d',
  [Report.ScannedObjectCount, Report.RemovedObjectCount]));
Writeln(Format('split text/path/image: %d / %d / %d',
  [Report.SplitTextObjectCount, Report.SplitPathObjectCount,
   Report.SplitImageObjectCount]));
Writeln(Format('preserved %d fragment(s)', [Report.PreservedFragmentCount]));
Writeln(Format('pruned %d resource name(s), swept %d object(s)',
  [Report.ResourcePruneReport.RemovedNameCount,
   Report.ResourcePruneReport.RemovedObjectCount]));

if Report.PreservedFragmentCount = 0 then
  // inget kunde delas: varje skärande objekt släpptes helt
  LogWholeObjectFallback(SourcePdf);

Varför faller PDFiumPas stängt vid omappade tecken?

Eftersom en glyf som inte har någon reproducerbar Unicode-skalär inte kan byggas om ärligt. Att rekonstruera en överlevande innebär att anropa API:et för textsättning med en sträng, och det kräver en stabil kodpunkt för varje behållet tecken. Symboliska delmängdstypsnitt med trasig eller frånvarande ToUnicode-data kan ge en tom mappning, och omkodning på gissning skulle framställa utdata som ser korrekt ut på skärmen medan den bär ett annat tecken under. PDFiumPas vägrar: kontrollen av behållna tecken utlöser ett undantag, undantaget fångas inuti SaveAsRedacted, TPdfRedactionReport.Succeeded kommer tillbaka som False med meddelandet i ErrorMessage, och funktionen returnerar False. Samma regel gäller uppdelningsbudgeten, som utlöser ett undantag snarare än att tyst hugga av fragmentmängden. När ett dokument har typsnitt du inte litar på och du vill ha det deterministiska gamla beteendet, sätt Options.PreservePartialObjects := False så försvinner varje skärande objekt helt

Resursrensning över delade räckvidder

Att dela objekt lämnar föräldralösa resurser efter sig, och att rensa dem är inte så enkelt som att jämföra sidans /Resources-ordbok. ISO 32000-1 §7.8.3 låter samma resursordbok refereras av flera sidor, av Form-XObject, av mönster och av annoteringars utseendeströmmar på en gång. Att ta bort ett typsnittsnamn för att en sida slutat använda det bryter en annan sida som fortfarande gör det. PruneUnusedPdfResources arbetar därför per räckvidd: den löser upp /Contents vare sig det är en direkt array, en indirekt referens till en array eller en enda ström, och samlar sedan resursanvändning från de operatorer som faktiskt namnger resurser — Tf för typsnitt, Do för XObject, gs för grafiktillstånd, CS, cs, SCN och scn för färgrymder och mönster, sh för skuggningar, BDC och DP för egenskaper i markerat innehåll, plus /CS-posten i inline-bilder. När en ordbok delas av flera räckvidder unioneras mängderna av använda namn per kategori innan något tas bort

Resursrensning i PDFiumPas: tre räckvidder refererar till en delad resursordbok, deras mängder av använda namn unioneras per kategori, och bara de namn ingen räckvidd refererar till tas bort innan onåbara objekt sopas undan
En ordbok kan tjäna flera sidor, form-XObject och utseendeströmmar, så PDFiumPas unionerar varje mängd av använda namn innan ett enda namn släpps

Endast namn som bekräftats orefererade över varje räckvidd som pekar på ordboken släpps. En räckvidd som inte kan tolkas med säkerhet lämnas orörd, vilket är den försiktiga riktningen: en orensad fil är bara större, en felaktigt rensad är korrupt. De överlevande ordböckerna skrivs tillbaka som en gles inkrementell uppdatering som bär de exakta generationsnumren, och en omskrivning för nåbarhet sopar sedan undan de objekt som blev onåbara när namnen försvann. TPdfResourcePruneReport rapporterar ScannedScopeCount, UpdatedScopeCount, RemovedNameCount, RemovedObjectCount, byteantalen och en Succeeded-flagga. SaveAsRedacted kör det här steget automatiskt på den sanerade utdatan, så maskeringsvägen inkluderar det redan, men funktionen är exporterad på strömnivå för pipelines som vill ha den för sig

uses
  FPdfCompress;

procedure PruneResourceNames(const SourcePdf, TargetPdf: string);
var
  Source, Dest: TFileStream;
  Report: TPdfResourcePruneReport;
begin
  Source := TFileStream.Create(SourcePdf, fmOpenRead or fmShareDenyWrite);
  try
    Dest := TFileStream.Create(TargetPdf, fmCreate);
    try
      // AllowSignedDocument förblir False: en inkrementell omskrivning skulle
      // ogiltigförklara de byteintervall en signatur täcker
      PruneUnusedPdfResources(Source, Dest, Report);
      if not Report.Succeeded then
        raise Exception.Create(Report.ErrorMessage);
      Writeln(Format('%d name(s) removed from %d scope(s), %d -> %d bytes',
        [Report.RemovedNameCount, Report.UpdatedScopeCount,
         Report.SourceByteCount, Report.OutputByteCount]));
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end;

Att koppla in det i en dokumentpipeline

Maskeringsvägen muterar aldrig dokumentet du läste in. SaveAsRedacted fångar en isolerad ögonblicksbild, tillämpar /Redact-annoteringarna där, skalar bort bilagor, kör det saneringspass som tar bort öppningsåtgärden, katalogåtgärderna, namnträden, associerade filer, AcroForm och metadatan, rensar resurser, och skriver först då utdataströmmen. Att öppna den utdatan igen som ett självständigt dokument och extrahera texten på nytt är det verifieringssteg som är värt att behålla i din egen testsvit, eftersom det är den enda kontroll som besvarar den ursprungliga frågan — kan en läsare fortfarande komma åt strängen. En följd att planera för: uppdelning ersätter sidobjekt, så varje FPDF_PAGEOBJECT-handtag du höll är dött efteråt, samma livslängdsfälla som beskrivs i inaktuella sidobjekthandtag efter en transform

Två angränsande delar gör arbetsflödet komplett. Att bestämma var maskeringsrektanglarna ska ligga börjar oftast i extraherad geometri, och block- och läsordningsmodellen i strukturerade textblock och läsordning är en bättre källa till kandidatrutor än råa teckenkörningar. Att servera resultatet till en granskare hör till härdningsreglerna i att bygga en säker PDF-förhandsvisning, där formulärifyllning och JavaScript förblir avstängda som standard. Tillsammans täcker de den slinga de flesta efterlevnadsarbetsflöden behöver: lokalisera, maskera på operatornivå, verifiera genom att öppna om, förhandsvisa säkert. Den fullständiga API-ytan, testnedladdningen och licensvillkoren för komponenten finns på produktsidan för PDFium Delphi Component