Technický článek

Redakce PDF na úrovni operátorů v Delphi s PDFiumPas

Někdo natře černý rámeček přes jméno, nic nezploští, odešle soubor a revidující vybere obdélník a vloží jméno do e-mailu. PDFiumPas na to odpovídá redakcí na úrovni operátorů: SaveAsRedacted smaže jen ty Unicode skaláry, jejichž rámečky znaků se dotknou redakčního obdélníku, znovu postaví přeživší z původního fontu, velikosti, matice, renderovacího režimu a barvy a ořízne obdélníkové cesty a obrázky, místo aby je zahodil celé

Proč natřený obdélník není redakce

Kreslicí operace přidaná na obsahový proud nic neskrývá, protože operace zobrazující text pod ní jsou stále v proudu a stále mapují na code points. ISO 32000-1 §9.4 definuje textový objekt jako sekvenci operací pozicování a zobrazování uvnitř BT a ET; vyplněný obdélník nakreslený poté je prostě další operace ve stejném proudu. Extrakce jde po operacích, ne po pixelech, takže překrytý řetězec se vrátí nedotčen. Skutečná redakce musí odstranit operand, ne zatemnit výstup

Očividná bezpečná implementace je brutální: najít každý objekt stránky, jehož ohraničující rámeček protíná redakční obdélník, a smazat celý objekt. To dělaly dřívější verze PDFiumPas a je to správné, ale drahé. Jediné Tj může nést celý řádek tabulky, takže začernění jednoho čísla účtu vzalo s sebou datum, popis i částku. Obdélníková výplň, která náhodou byla pásem přes celou šířku tabulky, zmizela přes celou stránku. Logo faktury zmizelo, protože redakce ořezala jeden jeho roh. Verze 3.101.0 posouvá rozhodnutí o úroveň níž, z objektu stránky na operand

Co redakce na úrovni operátorů skutečně maže?

PDFiumPas maže Unicode skaláry, nikoli textové objekty. Během SaveAsRedacted komponenta postaví mapování znaků na objekty stránky z načtené textové stránky, pak pro každý znak vlastněný testovaným objektem načte rámeček znaku a protíná tento rámeček s každým redakčním obdélníkem. Znaky dotýkající se obdélníku jsou označeny k odstranění; zbytek je označen jako přeživší. Není-li čeho protínat, objekt se nechá úplně stranou. Protíná-li každý znak, objekt se odstraní celý, přesně jako dřív. Jen smíšený případ spustí dělení

Redakce PDFiumPas na úrovni operátorů srovnána s mazáním celých objektů v Delphi: stará cesta upustí celý textový objekt, když je překryto jedno číslo účtu, zatímco cesta dělení smaže jen protínající znaky a znovu vydá každého přeživšího jako vlastní textový objekt
Jen smíšený případ spustí dělení: nic neprotínající nechá objekt stranou, vše protínající jej odstraní celý

Každý přeživší se pak znovu vydá jako vlastní textový objekt postavený z původního handle fontu, původní velikosti fontu, textové matice na znak, původního renderovacího režimu textu a stavu výplně a tahu rodičovského objektu včetně šířky tahu, spojení line, zakončení line a dash pole. Znovupoužití handle fontu místo vyřešení nového je to, co drží glyfy metrickky identické, a znovupoužití matice na znak je to, co drží kerning a mezery slov na místě bez opětovného rozvržení. Cena je počet objektů: jeden ponechaný znak se stane jedním textovým objektem, proto TPdfRedactionOptions.MaxSplitObjects existuje jako tvrdý strop generovaných fragmentů

procedure RedactDocument(const SourcePdf, TargetPdf: string);
var
  Pdf: TPdf;
  Options: TPdfRedactionOptions;
  Report: TPdfRedactionReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := SourcePdf;   // soubor už nese anotace /Redact
    Pdf.Active := True;

    Options := TPdfRedactionOptions.Default;
    Options.PreservePartialObjects := True;    // dělení na úrovni operátorů (výchozí)
    Options.RemoveIntersectingAnnotations := True;
    Options.MaxSplitObjects := 20000;          // strop generovaných fragmentů

    if not Pdf.SaveAsRedacted(TargetPdf, Options, Report) then
      raise Exception.Create(Report.ErrorMessage);   // selhání uzavřeně, neposílat
  finally
    Pdf.Free;
  end;
end;

Obdélníky se ořezávají, otočená geometrie ne

Cesty se dělí jen tehdy, když PDFiumPas dokáže dokázat, že cesta je obdélník vyrovnaný s osami. Důkaz je záměrně úzký: matice objektu musí mít oba shear členy pod 0.0001, cesta musí sestávat ze čtyř až šesti segmentů začínajících MOVETO a pokračujících jen LINETO a transformované body musí dopadnout na všechny čtyři rohy mezí objektu v toleranci 0.01. Cesta, která kontrolu překlene, se redukuje postupným odečítáním obdélníků, každý redakční obdélník vytesá ze souboru přeživších pruhy vlevo, vpravo, níže a výše a každý vzniklý pruh se znovu vytvoří s původním režimem výplně, příznakem tahu a stavem malby. Křivky, trojúhelníky, oříznuté tvary a cokoli otočeného kontrolou neprojde a celý objekt se odstraní

Obrázky následují ISO 32000-1 §8.9, kde vzorky obrázku obsazují jednotkový čtverec mapovaný aktuální transformační maticí. PDFiumPas to mapování invertuje, aby každý přeživší fragment v prostoru stránky vrátil zpět do normalizovaných souřadnic obrázku, sevře je do jednotkového intervalu a pak převede na pixelové indexy zaokrouhlením dovnitř: levá a horná hrana jdou přes Ceil, pravá a spodní přes Floor. Ten směr rozhoduje. Zaokrouhlení ven by dovolilo částečnému sloupci zdrojových pixelů z redigované strany přežít na hraně fragmentu. Celočíselné pixelové meze se pak převedou zpět do normalizovaných souřadnic a použijí k odvození matice fragmentu, takže oříznutá bitmapa dopadne přesně na pixelovou hranici, na které byla řezána. Samotný řez je kopie řádků vědomá stride přes formáty Gray, BGR, BGRx a BGRA. Jako u cest, otočený nebo zkosený obrázek, nebo jeden, jehož matice má degenerovaný člen měřítka, se odstraní celý

Jak PDFiumPas ořezává částečně redigovaný obrázek v Delphi: přeživší fragment v prostoru stránky se přes invertovanou CTM mapuje zpět do normalizovaných souřadnic obrázku, sevře do jednotkového intervalu a zaokrouhlí dovnitř, takže nepřežije žádný redigovaný pixelový sloupec
Ceil vlevo a nahoře, Floor vpravo a dole, takže řez dopadne na celou pixelovou hranici
// Po úspěšném volání 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
  // nic nešlo dělit: každý protínající objekt upadl celý
  LogWholeObjectFallback(SourcePdf);

Proč PDFiumPas selhává uzavřeně na nemapovaných znacích?

Protože glyf bez reprodukovatelného Unicode skaláru nelze poctivě znovu postavit. Rekonstrukce přeživšího znamená zavolat API nastavující text s řetězcem a to vyžaduje stabilní code point pro každý ponechaný znak. Symbolické subset fonty s rozbitými nebo chybějícími daty ToUnicode mohou dát prázdné mapování a znovukódování odhadem by vytvořilo výstup, který na obrazovce vypadá správně, zatímco nese dole jiný znak. PDFiumPas odmítá: kontrola ponechaných znaků vyvolá, výjimka je chycena uvnitř SaveAsRedacted, TPdfRedactionReport.Succeeded se vrátí False se zprávou v ErrorMessage a funkce vrátí False. Táž pravidla platí pro rozpočet dělení, který vyvolá, místo aby tiše zkrátil sadu fragmentů. Když dokument má fonty, kterým nevěříte, a chcete deterministické staré chování, nastavte Options.PreservePartialObjects := False a každý protínající objekt zmizí celý

Vyklepávání zdrojů napříč sdílenými rozsahy

Dělení objektů zanechává sirotky a jejich vyklepávání není tak jednoduché jako diff slovníku /Resources na úrovni stránky. ISO 32000-1 §7.8.3 dovoluje, aby na tentýž zdrojový slovník odkazovalo zároveň několik stránek, Form XObjects, vzory a proudy vzhledu anotací. Smazání názvu fontu, protože jedna stránka přestala používat, rozbije jinou stránku, která ho stále používá. PruneUnusedPdfResources proto pracuje na rozsah: vyřeší /Contents, ať je to přímé pole, nepřímá reference na pole, nebo jediný proud, pak sbírá používání zdrojů z operací, které zdroje skutečně pojmenovávají — Tf pro fonty, Do pro XObjects, gs pro grafický stav, CS, cs, SCN a scn pro barevné prostory a vzory, sh pro stínování, BDC a DP pro vlastnosti marked-content, plus položku /CS inline obrázků. Když jeden slovník sdílí několik rozsahů, sady použitých názvů se spojí na kategorii, než se cokoli smaže

Vyklepávání zdrojů v PDFiumPas: tři rozsahy odkazují na jeden sdílený zdrojový slovník, jejich sady použitých názvů se spojí na kategorii a jen názvy, na které neodkazuje žádný rozsah, se smaží, než se zametou nedosažitelné objekty
Jeden slovník může sloužit několika stránkám, form XObjects a proudům vzhledu, takže PDFiumPas spojí každou sadu použitých názvů, než upustí jediný název

Upustí se jen názvy potvrzené neodkazované napříč každým rozsahem ukazujícím na slovník. Rozsah, který nelze s jistotou naparsovat, zůstane nedotčen, což je konzervativní směr: nevyklepaný soubor je jen větší, špatně vyklepaný je corrupt. Přeživší slovníky se zapíší zpět jako řídká inkrementální aktualizace nesoucí přesná čísla generací a přepis dosažitelnosti pak zamete objekty, které se staly nedosažitelnými, jakmile názvy zmizely. TPdfResourcePruneReport hlásí ScannedScopeCount, UpdatedScopeCount, RemovedNameCount, RemovedObjectCount, počty bajtů a příznak Succeeded. SaveAsRedacted spouští tento krok automaticky na sanitizovaném výstupu, takže redakční cesta jej už zahrnuje, ale funkce je exportována na úrovni proudu pro pipeline, které ji chtějí samostatně

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 zůstává False: inkrementální přepis by
      // zneplatnil bajtové rozsahy, které podpis pokrývá
      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;

Zapojení do pipeline dokumentů

Redakční cesta nikdy nemutuje dokument, který jste načetli. SaveAsRedacted pořídí izolovaný snímek, aplikuje tam anotace /Redact, zbaví příloh, spustí sanitizační průchod odstraňující otevřenou akci, akce katalogu, stromy názvů, asociované soubory, AcroForm a metadata, vyklepe zdroje a teprve pak zapíše výstupní proud. Znovuotevření toho výstupu jako nezávislého dokumentu a opětovná extrakce textu je ověřovací krok, který stojí za podržení ve vlastní testovací sadě, protože je to jediná kontrola, která odpoví na původní otázku — může čtenář řetězec ještě dostat. Jeden důsledek k plánování: dělení nahrazuje objekty stránek, takže jakýkoli handle FPDF_PAGEOBJECT, který jste drželi, je poté mrtev, stejná past životnosti popsaná v článku zastaralé handle objektů stránky po transformaci

Dva sousedící díly dělají workflow celým. Rozhodnutí, kam redakční obdélníky padnou, obvykle začíná z extrahované geometrie a model bloků a pořadí čtení v strukturovaných textových blocích a pořadí čtení je lepším zdrojem kandidátních rámečků než surové běhy znaků. Podání výsledku revidujícímu patří k pravidlům zpevnění v stavbě bezpečného náhledu PDF, kde vyplňování formulářů a JavaScript zůstávají ve výchozím stavu vypnuty. Společně pokrývají smyčku, kterou většina compliance workflow potřebuje: najít, redigovat na úrovni operátorů, ověřit znovuotevřením, náhled bezpečně. Úplný povrch API, zkušební stažení a licenční podmínky komponenty žijí na produktové stránce PDFium Delphi Component