Teknisk artikel

Kollationer duplex-scanninger i Delphi: PDF-fletning

CollateDocumentsEx i PDFlibPas Delphi PDF-biblioteket fletter flere åbne dokumenter til ét interleaved dokument. Funktionen tilføjer GroupSize sider fra hver kilde pr. runde, accepterer en side-intervalliste pr. kilde, og behandler et faldende interval som 3-1 som en omvendt gennemgang af den kilde. Ét kald forvandler en forside-stak og en omvendt bagside-stak til læserækkefølge

Scenariet bag den API er hverdagsagtigt og udbredt. En arkscanner med enkeltsidet bane kører hele stakken med forsiden nedad, hvorefter operatøren vender stakken og kører den igen. Man ender med to PDF'er: forsider i rækkefølge, bagsider i omvendt rækkefølge. Det, brugeren vil have, er én fil: side 1 forside, side 1 bagside, side 2 forside, og så videre. Denne artikel handler om rækkefølgeproblemet og fælden med ressourceduplikering, der ligger under det. Er dit fokus i stedet ren sammenkædnings-throughput, se hurtig PDF-fletning med byte-niveau ref-forskydning; hvis inputfilerne er for store til at holde i hukommelsen overhovedet, se fletning og opdeling af PDF'er i gigabyte-størrelse med direkte adgang

Scanneren producerer to stakke, hvoraf den ene er omvendt

Kollationering er ikke fletning. En fletning sammenkæder side-intervaller; en kollationering interleaver dem, og interleave-mønsteret er en egenskab ved den fysiske enhed, der producerede input. Rammer man mønsteret forkert, er filen ikke bare lidt forkert — den er ulæselig: hver anden side hører til et andet ark. Tre variabler beskriver næsten ethvert virkeligt tilfælde: hvor mange kilder indgår i rotationen, hvor mange sider der kommer fra hver kilde pr. runde, og om nogen kilde skal læses baglæns. CollateDocuments dækker de to første med et simpelt array af dokumenthandles og et heltal GroupSize. CollateDocumentsEx tilføjer den tredje ved at acceptere en semikolon-adskilt liste af side-intervaller, ét segment pr. kilde, hvor et tomt segment betyder alle sider fra den kilde, og et faldende interval vender den om. Begge funktioner tilføjer i slutningen af det aktuelt valgte dokument og returnerer 1 ved succes, 0 ved enhver afvisning

Hvorfor multiplicerer den naive kollationering filstørrelsen?

Fordi importkortet, der mapper kildens objektnumre til målets objektnumre, genopbygges ved hvert kopikald, og alt, hvad der er tilgængeligt fra mere end én blok, bliver importeret én gang pr. blok. Inden i PDFlibPas nulstiller TPDFDocument.CopyPagesFromDoc sin NewIndObjList ved starten af hvert kald. Den liste er den eneste hukommelse, kopieringen har om, hvad den allerede har overført. Kald den én gang med et ti-siders interval, og en skrifttype delt af alle ti sider indlejres én gang. Kald den ti gange med én side ad gangen, og den samme skrifttype indlejres ti gange. Dette betyder langt mere for scanninger end for tekstdokumenter, fordi en scannet side er ét stort billed-XObject, og de delte objekter er dem med reel vægt: en indlejret ICC-profil, en delt /DecodeParms-kæde, en stempel- eller vandmærke-formular-XObject anvendt på hvert ark, OCR-tekstlagets skrifttype. Den oplagte måde at skrive en round-robin-kollationering på er en løkke over runder, og den løkke er netop det patologiske tilfælde

// Do not do this. Each CopyPageRanges call rebuilds the import map,
// so anything the two sources share internally is imported once per
// round instead of once per source.
var
  RoundIndex: Integer;
begin
  for RoundIndex := 1 to 12 do
  begin
    PDF.CopyPageRanges(Fronts, IntToStr(RoundIndex));
    PDF.CopyPageRanges(Backs, IntToStr(13 - RoundIndex));
  end;
end;

Tolv runder, to kilder, fireogtyve importkort. Intet advarer dig. Siderækkefølgen er korrekt, hver side gengives, og det eneste symptom er en fil, der er adskillige gange større end summen af sine input. På en batch-opgave med 300 sider er multiplikatoren ikke en afrundingsfejl — den er forskellen på et arkiv, der overholder opbevaringsbudgettet, og ét, der ikke gør

Importer én gang, omorganisér så sidetræet

Løsningen er at adskille de to hensyn, som den naive løkke havde smeltet sammen. Kopiering afgør, hvilke objekter der findes i målet; rækkefølge afgør, hvor siderne sidder i sidetræet. CollateDocumentsEx kopierer hver kilde nøjagtigt én gang, i ét enkelt CopyPagesFromDoc-kald med kildens komplette interval, så hver kilde får ét importkort, og delte ressourcer skrives kun én gang. Først når hver kilde er på plads, sker interleavingen, og den sker udelukkende gennem TPDFPageTree.MovePage

Sideflytninger er gratis i den forstand, der betyder noget her. ISO 32000-1 §7.7.3 definerer sidetræet som en balanceret struktur af node-dictionaries, hvis /Kids-arrays holder indirekte referencer, med /Count, der bærer bladtallet ved hver node. At flytte en side betyder at fjerne én indirekte reference fra ét /Kids-array, indsætte den i et andet, justere begge /Count-værdier og pege sidens /Parent om. Ingen indholdsstrøm røres, ingen ressource duplikeres, intet objekt oprettes. Sideobjektet beholder sit objektnummer, hvilket også er grunden til, at objektnumrene forbliver stabile på samme måde som i sideudskiftning, der bevarer objektnumre. Der er én yderligere detalje, som en naiv sideflytning rammer forkert, og som MovePage ikke gør. ISO 32000-1 §7.7.3.4 lader /Resources, /MediaBox, /CropBox og /Rotate arves fra en forfader-node i stedet for at være angivet på siden. En side, der arver sine ressourcer fra node A og derefter flyttes under node B, arver stiltiende noget andet — eller slet intet. MovePage løser derfor den arvede værdi og skriver den ind på sidedictionary'et før flytningen, så siden bærer sine egne attributter med sig gennem flytningen

Hvad gør omordningsgennemløbet egentlig?

Det kører en selection sort mod insert-at-semantik. Den ønskede blokrelative rækkefølge beregnes først: gennemgå kilderne i rotation, tag op til GroupSize indekser fra hver, spring en udtømt kilde over, gentag indtil hver side er placeret. Det giver en permutation over den tilføjede blok. At anvende den er den akavede del, fordi MovePage er en indsættelse, ikke en ombytning, så hver flytning forskyder alt mellem den gamle og den nye position med ét

Implementeringen holder et Current-array, der modellerer, hvor hver tilføjet side aktuelt sidder, scanner fremad fra position K efter siden, der hører til der, udfører flytningen og lader så array-elementerne glide for at afspejle, hvad flytningen gjorde ved træet. Det er O(n kvadrat) i array-operationer og nul i objektkopier, hvilket er den korrekte afvejning for denne arbejdsbyrde: en kollationering på 500 sider er en kvart million heltalsflytninger og ikke én byte duplikerede billeddata. Faldende intervaller og gentagne sider kræver ingen særbehandling i dette gennemløb, fordi PLParsePageRangeList kaldes med sortering deaktiveret og duplikater tilladt, så den ønskede rækkefølge overlever parsingen intakt

Omvendte intervaller og duplex-fletningen med ét kald

Med en omvending udtrykt som et interval kollapser det dobbeltpassede flatbed-tilfælde til ét enkelt kald. Forsiderne ønsker deres naturlige rækkefølge, og bagsiderne ønsker 12-1, og det tomme første segment før semikolonet siger, at den første kilde bidrager med alle sine sider

var
  PDF: TPDFlib;
  Target, Fronts, Backs: Integer;
begin
  PDF := TPDFlib.Create;
  try
    Target := PDF.NewDocument;
    if PDF.LoadFromFile('fronts.pdf', '') <> 1 then
      Exit;
    Fronts := PDF.SelectedDocument;
    if PDF.LoadFromFile('backs.pdf', '') <> 1 then
      Exit;
    Backs := PDF.SelectedDocument;
    PDF.SelectDocument(Target);
    // fronts 1..12 in order, backs scanned in reverse: F1 B12 F2 B11 ...
    if PDF.CollateDocumentsEx([Fronts, Backs], ';12-1', 1) = 1 then
      PDF.SaveToFile('duplex.pdf');
  finally
    PDF.Free;
  end;
end;

To adfærdsmønstre i det uddrag fortjener at nævnes eksplicit. De kollationerede sider tilføjes til det valgte dokument, så et dokument oprettet med NewDocument bidrager med sin oprindelige blanke side forud for dem, og den bør slettes, hvis man ikke ønsker den. Og kilderne kan være ujævne: med GroupSize 2 over en tre-siders og en fem-siders kilde kommer runderne ud som A1 A2 B1 B2, derefter A3 B3 B4 når A næsten er brugt op, derefter B5 alene, fordi en udtømt kilde blot springes over i stedet for at blive polstret

Rollback, formularfelter, og hvad der ikke følger med

Alle argumenter valideres, før målet røres. Et manglende dokumenthandle, det valgte dokument opført som sin egen kilde, en GroupSize under ét, et segmenttal, der ikke matcher kildetallet, et interval, der navngiver en side kilden ikke har: alt dette returnerer 0 med målet uændret. Fejl under kopiering er det sværere tilfælde, og det håndteres gennem den offentlige DeletePages frem for det rå PageTree.DeletePages. Grunden er specifik. Kopieringen kører med MergeFormData aktiveret, så kildens formularfelter allerede er blevet tilføjet til målets /AcroForm /Fields-array, når en senere kilde fejler. At slette siderne på sidetræ-niveau ville fjerne widget-siderne og efterlade de feltreferencer hængende; den offentlige vej fjerner koblingen for felt-, oversigts- og artikeltråd-referencer sammen med siderne

if PDF.CollateDocumentsEx([Fronts, Backs], ';12-1', 1) = 0 then
  // Nothing was appended and the target is byte-identical to before.
  // 412 is the copy failure; 0 means the arguments were rejected
  // during validation, before any page was touched.
  Log(Format('collate rejected, LastErrorCode=%d', [PDF.LastErrorCode]));

Vær ærlig over for dine brugere om grænserne. Kollationeringen fører sider, deres annoteringer og deres formularfelter med sig, og den fletter AcroForm-feltlisten, beregningsrækkefølge-arrayet og standard-ressourcedictionary'et. Den fører ikke kildens bogmærker med sig: oversigtstræet i en scannet forside-stak er næsten altid tomt, så intet går tabt i duplex-tilfældet, men kollationerer man to redigerede dokumenter, forbliver deres oversigter tilbage, og navigationen skal genopbygges selv. Navngivne destinationer, der kun eksisterede i kildens katalog, er i samme situation. Planlæg for det, før man lover en kunde en tabsfri kollationering

PDFlibPas leverer kollationeringsfunktionerne sammen med resten af sin sammensætningsflade for sider, så scanner-workflowet, den intervalbaserede udtrækning og de store filers datastier alle sidder bag én komponent i Delphi og C++Builder. Den fulde API-reference og en trial-build findes på produktsiden for losLab Delphi PDF Library