Technisch artikel

Duplexscans collationeren in Delphi: PDF Interleave Merge

CollateDocumentsEx in de PDFlibPas Delphi PDF-bibliotheek voegt meerdere geopende documenten samen tot één geïnterleaved document. De functie voegt per ronde GroupSize pagina's van elke bron toe, accepteert per bron een lijst met paginabereiken, en behandelt een aflopend bereik zoals 3-1 als een omkering van die bron. Eén aanroep verandert een voorkantstapel en een omgekeerde achterkantstapel in leesvolgorde

Het scenario achter deze API is alledaags en zeer gangbaar. Een sheet-fed scanner met een enkelzijdig pad scant de hele stapel met de voorkant omlaag, waarna de operator de stapel omdraait en opnieuw scant. Het resultaat is twee PDF's: voorkanten in volgorde, achterkanten in omgekeerde volgorde. De gebruiker wil één bestand: pagina 1 voorkant, pagina 1 achterkant, pagina 2 voorkant, enzovoort. Dit artikel gaat over het ordeningsprobleem en de resource-duplicatieval die daaronder schuilgaat. Gaat het je om ruwe concatenatiedoorvoer, zie dan snelle PDF-merge met byte-level ref shifting; als de invoer te groot is om volledig in het geheugen te houden, zie dan gigabyte-PDF's samenvoegen en splitsen met directe toegang

De scanner produceert twee stapels, en een ervan staat achterstevoren

Collationeren is geen samenvoegen. Een merge voegt paginabereiken aaneen; een collatie interleaved ze, en het interleave-patroon is een eigenschap van het fysieke apparaat dat de invoer heeft geproduceerd. Krijg je het patroon verkeerd, dan is het bestand niet een beetje fout, het is onleesbaar: elke tweede pagina hoort bij een ander vel. Drie variabelen beschrijven bijna elk praktijkgeval: hoeveel bronnen er in de rotatie zitten, hoeveel pagina's per ronde van elke bron komen, en of een bron achterstevoren gelezen moet worden. CollateDocuments dekt de eerste twee met een simpele array van documenthandles en een GroupSize-integer. CollateDocumentsEx voegt de derde toe door een puntkomma-gescheiden lijst met paginabereiken te accepteren, één segment per bron, waarbij een leeg segment alle pagina's van die bron betekent en een aflopend bereik die bron omkeert. Beide functies voegen toe aan het einde van het momenteel geselecteerde document en retourneren 1 bij succes, 0 bij elke afwijzing

Waarom vermenigvuldigt de naïeve collatie de bestandsgrootte?

Omdat de import-map die bronobjectnummers koppelt aan doelobjectnummers bij elke kopieeraanroep opnieuw wordt opgebouwd, en alles wat vanuit meer dan één chunk bereikbaar is, per chunk opnieuw geïmporteerd wordt. Binnen PDFlibPas reset TPDFDocument.CopyPagesFromDoc zijn NewIndObjList aan het begin van elke aanroep. Die lijst is het enige geheugen dat de kopieerder heeft van wat al is overgebracht. Roep je hem eenmaal aan met een bereik van tien pagina's, dan wordt een font dat door alle tien pagina's wordt gedeeld eenmalig ingebed. Roep je hem tien keer aan met telkens één pagina, dan wordt datzelfde font tien keer ingebed. Dit weegt veel zwaarder bij scans dan bij tekstdocumenten, omdat een gescande pagina één grote afbeeldings-XObject is en de gedeelde objecten juist de zware zijn: een ingebed ICC-profiel, een gedeelde /DecodeParms-keten, een stempel- of watermerk-form-XObject dat op elk vel wordt toegepast, het font van de OCR-tekstlaag. De voor de hand liggende manier om een round-robin-collatie te schrijven is een lus over rondes, en die lus is precies het pathologische geval

// 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;

Twaalf rondes, twee bronnen, vierentwintig import-maps. Niets waarschuwt je. De paginavolgorde klopt, elke pagina rendert, en het enige symptoom is een bestand dat meerdere malen groter is dan de som van zijn invoer. Bij een batchjob van 300 pagina's is de vermenigvuldigingsfactor geen afrondingsfout, het is het verschil tussen een archief dat binnen het retentiebudget past en een dat dat niet doet

Eenmaal importeren, dan de paginaboom herordenen

De oplossing bestaat uit het scheiden van de twee zaken die de naïeve lus had samengesmolten. Kopiëren bepaalt welke objecten in het doel bestaan; ordenen bepaalt waar de pagina's in de paginaboom staan. CollateDocumentsEx kopieert elke bron precies één keer, in één enkele CopyPagesFromDoc-aanroep met het volledige bereik van die bron, zodat elke bron één import-map krijgt en gedeelde resources eenmalig worden weggeschreven. Pas nadat elke bron is geland, vindt het interleaven plaats, en dat gebeurt volledig via TPDFPageTree.MovePage

Paginaverplaatsingen zijn gratis in de zin die hier telt. ISO 32000-1 §7.7.3 definieert de paginaboom als een gebalanceerde structuur van node-dictionaries waarvan de /Kids-arrays indirecte referenties bevatten, met /Count die het totaal aantal bladeren per node bijhoudt. Een pagina verplaatsen betekent één indirecte referentie uit één /Kids-array verwijderen, invoegen in een andere, beide /Count-waarden aanpassen, en de /Parent van de pagina herijken. Geen contentstream wordt aangeraakt, geen resource wordt gedupliceerd, geen object wordt aangemaakt. Het paginaobject behoudt zijn objectnummer, wat ook de reden is waarom objectnummers stabiel blijven zoals beschreven in paginavervanging met behoud van objectnummers. Er is nog een detail dat een naïeve paginaverplaatsing fout doet, maar MovePage niet. ISO 32000-1 §7.7.3.4 laat /Resources, /MediaBox, /CropBox en /Rotate overerven van een voorouderknoop in plaats van op de pagina zelf te staan. Een pagina die zijn resources erft van node A en vervolgens naar node B wordt verplaatst, erft stilzwijgend iets anders, of helemaal niets. MovePage lost daarom de overgeërfde waarde op en schrijft die naar het paginadictionary voordat de verplaatsing plaatsvindt, zodat de pagina zijn eigen attributen meeneemt over de verplaatsing heen

Wat doet de herordeningspas eigenlijk?

Er wordt een selectiesort uitgevoerd tegen insert-at-semantiek. De gewenste blok-relatieve volgorde wordt eerst berekend: loop de bronnen in rotatie door, neem tot GroupSize indices van elke bron, sla een uitgeputte bron over, herhaal tot elke pagina geplaatst is. Dat levert een permutatie op over het toegevoegde blok. Het toepassen ervan is het lastige deel, omdat MovePage een insert is, geen swap, dus elke verplaatsing schuift alles tussen de oude en de nieuwe positie met één op

De implementatie houdt een Current-array bij die modelleert waar elke toegevoegde pagina zich op dit moment bevindt, scant vooruit vanaf positie K naar de pagina die op K hoort, geeft de verplaatsing door, en schuift dan de array-entries op om te weerspiegelen wat de verplaatsing met de boom heeft gedaan. Dat is O(n kwadraat) in array-bewerkingen en nul in objectkopieën, wat de juiste afweging is voor deze workload: een collatie van 500 pagina's is een kwart miljoen integer-verschuivingen en geen enkele byte gedupliceerde beelddata. Aflopende bereiken en herhaalde pagina's hoeven in deze pas geen speciale behandeling, omdat PLParsePageRangeList wordt aangeroepen met sorteren uitgeschakeld en duplicaten toegestaan, zodat de gevraagde volgorde intact het parseerproces doorstaat

Omgekeerde bereiken en de duplexmerge in één aanroep

Met omkering uitgedrukt als een bereik, valt het flatbed-dubbelpasgeval samen tot één enkele aanroep. De voorkanten willen hun natuurlijke volgorde en de achterkanten willen 12-1, en het lege eerste segment vóór de puntkomma zegt dat de eerste bron al zijn pagina's bijdraagt

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;

Twee gedragingen in dat fragment zijn het waard om expliciet te benoemen. De gecollationeerde pagina's worden toegevoegd aan het geselecteerde document, dus een document dat met NewDocument is aangemaakt draagt zijn oorspronkelijke lege pagina bij vóór hen, en je moet die verwijderen als je hem niet wilt. En de bronnen mogen ongelijk zijn: met GroupSize 2 over een bron van drie pagina's en een bron van vijf pagina's komen de rondes uit als A1 A2 B1 B2, dan A3 B3 B4 zodra A bijna op is, dan B5 alleen, omdat een uitgeputte bron simpelweg wordt overgeslagen in plaats van opgevuld

Rollback, formuliervelden, en wat niet meekomt

Elk argument wordt gevalideerd voordat het doel wordt aangeraakt. Een ontbrekende documenthandle, het geselecteerde document dat als zijn eigen bron wordt opgegeven, een GroupSize onder één, een segmentaantal dat niet overeenkomt met het aantal bronnen, een bereik dat een pagina noemt die de bron niet heeft: dit alles retourneert 0 met het doel ongewijzigd. Falen tijdens het kopiëren is het lastigere geval, en dat wordt afgehandeld via de publieke DeletePages in plaats van de ruwe PageTree.DeletePages. De reden is specifiek. Het kopiëren draait met MergeFormData ingeschakeld, dus de formuliervelden van de bron zijn al toegevoegd aan de /AcroForm /Fields-array van het doel tegen de tijd dat een latere bron faalt. Het verwijderen van de pagina's op paginaboomniveau zou de widgetpagina's wegstrippen en die veldreferenties bungelend achterlaten; het publieke pad ontkoppelt de veld-, outline- en artikeldraad-referenties samen met de pagina's

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]));

Wees eerlijk tegen je gebruikers over de grenzen. De collatie draagt pagina's, hun annotaties en hun formuliervelden mee, en voegt de AcroForm-veldlijst, de berekeningsvolgorde-array en het standaard resources-dictionary samen. Bronbladwijzers worden niet meegedragen: de outlineboom van een gescande voorkantstapel is bijna altijd leeg, dus in het duplexgeval gaat er niets verloren, maar als je twee geauteurde documenten collationeert, blijven hun outlines achter en moet je de navigatie zelf herbouwen. Named destinations die alleen in de bron-catalogus bestonden, verkeren in dezelfde positie. Plan daarop voordat je een klant een verliesvrije collatie belooft

PDFlibPas levert de collatiefuncties samen met de rest van zijn paginasamenstellingsoppervlak, zodat de scannerworkflow, de op bereik gebaseerde extractie en de large-file-paden allemaal achter één component in Delphi en C++Builder zitten. De volledige API-referentie en een proefversie staan op de productpagina van de losLab Delphi PDF-bibliotheek