Odborný článok

Redakcia PDF na úrovni operátorov v Delphi s PDFiumPas

Niekto nakreslí čierny rámik cez meno, nič neslúči, odošle súbor a recenzent vyberie obdĺžnik a vloží meno do e-mailu. PDFiumPas na to odpovedá redakciou na úrovni operátorov: SaveAsRedacted zmaže len Unicode skaláry, ktorých rámečky znakov sa dotýkajú redakčného obdĺžnika, prebuduje preživších z pôvodného písma, veľkosti, matice, render režimu a farby a oreže cesty a obrázky zarovnané k osiam namiesto toho, aby ich zahodil celé

Prečo nakreslený obdĺžnik nie je redakcia

Kresliaca operácia pridaná navrch obsahového prúdu nič neskrýva, pretože operácie zobrazujúce text pod ňou sú stále v prúde a stále sa mapujú na kódové body. ISO 32000-1 §9.4 definuje textový objekt ako postupnosť operácií umiestňovania a zobrazovania vnútri BT a ET; vyplnený obdĺžnik nakreslený potom je jednoducho ďalšia operácia v tom istom prúde. Extrakcia prechádza operáciami, nie pixelmi, takže zakrytý reťazec sa vráti nedotknutý. Skutočná redakcia musí odstrániť operand, nie zatemniť výstup

Zjavná bezpečná implementácia je brutálna: nájsť každý objekt stránky, ktorého ohraničujúci rámec pretína redakčný obdĺžnik, a zmazať celý objekt. Presne to robili skoršie vydania PDFiumPas a je to správne, ale drahé. Jediný Tj môže niesť celý riadok tabuľky, takže zatienenie jedného čísla účtu vzalo so sebou dátum, popis aj sumu. Obdĺžniková výplň, ktorá náhodou tvorí pásmo tabuľky na celú šírku, zmizla po celej stránke. Logo faktúry zmizlo, pretože redakcia odstrihla jeden jeho roh. Verzia 3.101.0 posúva rozhodnutie o úroveň nižšie, z objektu stránky na operand

Čo redakcia na úrovni operátorov skutočne maže?

PDFiumPas maže Unicode skaláry, nie textové objekty. Počas SaveAsRedacted komponent vybuduje mapovanie znakov na objekty stránky z načítanej textovej stránky, potom pre každý znak vlastnený testovaným objektom prečíta jeho rámeček a pretína tento rámec s každým redakčným obdĺžnikom. Znaky, ktoré sa dotknú obdĺžnika, sa označia na odstránenie; ostatné sa označia ako preživší. Ak nič nepretína, objekt sa úplne nechá pokojne. Ak pretína každý znak, objekt sa zmaže celý, presne ako predtým. Len zmiešaný prípad spustí rozdelenie

Redakcia na úrovni operátorov PDFiumPas v porovnaní s mazaním celých objektov v Delphi: stará cesta zahodí celý textový objekt, keď je zakryté jedno číslo účtu, zatiaľ čo cesta delenia maže len pretínajúce znaky a každého preživšieho znovu vydá ako vlastný textový objekt
Len zmiešaný prípad spustí rozdelenie: nič pretínajúce nechá objekt pokojne, všetko pretínajúce ho zmaže celý

Každý preživší sa potom znovu vydá ako vlastný textový objekt postavený z pôvodného handle písma, pôvodnej veľkosti písma, textovej matice pre každý znak, pôvodného render režimu textu a stavu výplne a ťahu rodičovského objektu vrátane šírky ťahu, line join, line cap a dash array. Znovupoužitie handle písma namiesto vyriešenia nového je to, čo drží glyfy metricky identické, a znovupoužitie matice pre každý znak je to, čo drží kerning a medzery slov na mieste bez opätovného behu layoutu. Cena je počet objektov: jeden ponechaný znak sa stane jedným textovým objektom, a preto TPdfRedactionOptions.MaxSplitObjects existuje ako tvrdý strop počtu generovaných fragmentov

procedure RedactDocument(const SourcePdf, TargetPdf: string);
var
  Pdf: TPdf;
  Options: TPdfRedactionOptions;
  Report: TPdfRedactionReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := SourcePdf;   // súbor už nesie anotácie /Redact
    Pdf.Active := True;

    Options := TPdfRedactionOptions.Default;
    Options.PreservePartialObjects := True;    // delenie na úrovni operátorov (predvolené)
    Options.RemoveIntersectingAnnotations := True;
    Options.MaxSplitObjects := 20000;          // strop počtu generovaných fragmentov

    if not Pdf.SaveAsRedacted(TargetPdf, Options, Report) then
      raise Exception.Create(Report.ErrorMessage);   // fail closed, neodosielať
  finally
    Pdf.Free;
  end;
end;

Obdĺžniky sa orežú, otočená geometria nie

Cesty sa delia len vtedy, keď PDFiumPas dokáže, že cesta je obdĺžnik zarovnaný k osiam. Dôkaz je zámerne úzky: objektová matica musí mať oba šmykové členy pod 0.0001, cesta musí pozostávať zo štyroch až šiestich segmentov začínajúcich MOVETO a pokračujúcich len LINETO a transformované body musia dopadnúť na všetky štyri rohy ohraničenia objektu v tolerancii 0.01. Cesta, ktorá kontrolu prejde, sa redukuje postupným odčítaním obdĺžnikov, pričom každý redakčný obdĺžnik vyrubuje zo súboru preživších pásy vľavo, vpravo, pod a nad a každý výsledný pás sa znovu vytvorí s pôvodným režimom výplne, príznakom ťahu a stavom farbenia. Krivky, trojuholníky, vystrihnuté tvary a čokoľvek otočené kontrolu nezvládne a celý objekt sa odstráni

Obrázky sa riadia ISO 32000-1 §8.9, kde vzorky obrázku obsadzujú jednotkový štvorec zmapovaný cez aktuálnu transformačnú maticu. PDFiumPas toto mapovanie invertuje, aby každý preživší fragment v priestore stránky vrátil do normalizovaných súradníc obrázku, pritlačí ich do jednotkového intervalu a potom konvertuje na pixelové indexy zaokrúhlením dovnútra: ľavý a horný okraj prechádzajú cez Ceil, pravý a dolný cez Floor. Tento smer je dôležitý. Zaokrúhlenie smerom von by dovolilo, aby sa na okraji fragmentu zachovala čiastočná stĺpcová zložka zdrojových pixelov z redakčnej strany. Celočíselné pixelové medze sa potom prevedú späť na normalizované súradnice a použijú na odvodenie matice fragmentu, takže orezaná bitmapa dopadne presne na pixelovú hranicu, pri ktorej bola narezaná. Samotný rez je kópia riadkov s ohľadom na stride cez formáty Gray, BGR, BGRx a BGRA. Rovnako ako pri cestách sa otočený alebo zošikmený obrázok, alebo obrázok, ktorého matica má degenerovaný člen mierky, odstráni celý

Ako PDFiumPas orezáva čiastočne redigovaný obrázok v Delphi: preživší fragment v priestore stránky sa cez invertovanú CTM namapuje späť do normalizovaných súradníc obrázku, pritlačí do jednotkového intervalu a zaokrúhli dovnútra, takže žiadny redigovaný pixelový stĺpec neprežije
Ceil vľavo a hore, Floor vpravo a dole, takže rez dopadne na celú pixelovú hranicu
// Po úspešnom volaní 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
  // nič sa nedalo rozdeliť: každý pretínajúci objekt bol zahodený celý
  LogWholeObjectFallback(SourcePdf);

Prečo PDFiumPas zlyháva bezpečne pri nenamapovaných znakoch?

Pretože glyf bez reprodukovateľného Unicode skaláru sa nedá čestne prebudovať. Rekonštrukcia preživšieho znamená zavolať API nastavujúce text s reťazcom a to vyžaduje stabilný kódový bod pre každý ponechaný znak. Symbolické podmnožinové písma s pokazenými alebo chýbajúcimi dátami ToUnicode môžu dať prázdne mapovanie a rekódovanie odhadom by vyrobilo výstup, ktorý na obrazovke vyzerá správne, ale v skutočnosti nesie iný znak. PDFiumPas odmietne: kontrola ponechaných znakov vyvolá výnimku, výnimka sa zachytí vnútri SaveAsRedacted, TPdfRedactionReport.Succeeded sa vráti ako False so správou v ErrorMessage a funkcia vráti False. Toto isté pravidlo platí pre rozpočet delenia, ktorý vyvolá výnimku namiesto tichého skrátenia sady fragmentov. Ak dokument obsahuje písma, ktorým neveríte, a chcete deterministické staré správanie, nastavte Options.PreservePartialObjects := False a každý pretínajúci objekt zmizne celý

Orezávanie zdrojov cez zdieľané oblasti

Delenie objektov zanecháva siroty a ich orezávanie nie je také jednoduché ako porovnanie slovníka /Resources na úrovni stránky. ISO 32000-1 §7.8.3 dovoľuje, aby sa ten istý slovník zdrojov odkazoval z viacerých stránok, Form XObjects, vzorov aj prúdov vzhľadu anotácií naraz. Zmazanie názvu písma preto, že jedna stránka ho prestala používať, rozbije inú stránku, ktorá ho ešte používa. PruneUnusedPdfResources preto pracuje po oblastiach: vyrieši /Contents či už je to priame pole, nepriamy odkaz na pole alebo jediný prúd, potom zbiera použitie zdrojov z operácií, ktoré zdroje reálne menujú — Tf pre písma, Do pre XObjects, gs pre grafický stav, CS, cs, SCN a scn pre farebné priestory a vzory, sh pre tienenia, BDC a DP pre vlastnosti marked-content, plus položku /CS inline obrázkov. Keď jeden slovník zdieľa viac oblastí, množiny použitých názvov sa zjednotia po kategóriách skôr, než sa čokoľvek odstráni

Orezávanie zdrojov v PDFiumPas: tri oblasti odkazujú jeden zdieľaný slovník zdrojov, ich množiny použitých názvov sa zjednotia po kategóriách a odstránia sa len názvy, ktoré žiadna oblasť neodkazuje, skôr než sa zamestú nedosiahnuteľné objekty
Jeden slovník môže slúžiť viacerým stránkam, form XObjects a prúdom vzhľadu, preto PDFiumPas zjednotí každú množinu použitých názvov skôr, než zahodí jediný názov

Zahodia sa len názvy potvrdené ako neodkazované naprieč každou oblasťou, ktorá na slovník ukazuje. Oblasť, ktorú nemožno spoľahlivo vyparsovať, zostáva nedotknutá, a to je konzervatívny smer: neorezaný súbor je len väčší, zle orezaný je poškodený. Preživšie slovníky sa zapíšu späť ako riedka prírastková aktualizácia nesúca presné čísla generácií a prepis dosiahnuteľnosti potom zamestá objekty, ktoré sa po zmiznutí názvov stali nedosiahnuteľnými. TPdfResourcePruneReport hlási ScannedScopeCount, UpdatedScopeCount, RemovedNameCount, RemovedObjectCount, počty bajtov a príznak Succeeded. SaveAsRedacted spúšťa tento krok automaticky na sanitovanom výstupe, takže redakčná cesta ho už obsahuje, ale funkcia je exportovaná na úrovni prúdov pre pipeline, ktoré ju chcú samostatne

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 ostáva False: prírastkový prepis by
      // zneplatnil bajtové rozsahy, ktoré podpis kryje
      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;

Zapojenie do pipeline dokumentov

Redakčná cesta nikdy nemutuje dokument, ktorý ste načítali. SaveAsRedacted zachytí izolovanú snímku, aplikuje na nej anotácie /Redact, odstráni prílohy, spustí sanitizačný priechod odstraňujúci open action, akcie katalógu, stromy mien, pridružené súbory, AcroForm a metadata, oreže zdroje a až potom zapíše výstupný prúd. Znovuotvorenie tohto výstupu ako nezávislého dokumentu a opätovná extrakcia textu je overovací krok, ktorý stojí za to držať vo vlastnej testovacej sade, pretože je to jediná kontrola, ktorá odpovie na pôvodnú otázku — či sa čitateľ ešte môže k reťazcu dopracovať. Jedna následnosť, na ktorú sa treba pripraviť: delenie nahradzuje objekty stránky, takže každý handle FPDF_PAGEOBJECT, ktorý ste držali, je potom mŕtvy; to isté životné pasce popisuje zastarané handly objektov stránky po transformácii

Dva susediace kusy robia pracovný postup úplným. Rozhodnutie, kam redakčné obdĺžniky sadia, zvykne vychádzať z extrahovanej geometrie a model blokov a poradia čítania v štruktúrovaných textových blokoch a poradí čítania je lepším zdrojom kandidátskych rámečkov než surové behy znakov. Servírovanie výsledku recenzentovi patrí k pravidlám spevnenia v stavbe bezpečnej PDF ukážky, kde vypĺňanie formulárov a JavaScript zostávajú v predvolenom stave vypnuté. Spolu pokrývajú slučku, ktorú väčšina compliance pracovných postupov potrebuje: nájsť, redigovať na úrovni operátorov, overiť znovuotvorením, ukázať bezpečne. Úplný povrch API, skúšobné stiahnutie a licenčné podmienky komponentu nájdete na produktovej stránke PDFium Delphi Component