Artículo técnico

Dividir documentos PDF con el PDFium Component en Delphi

Para un manual de usuario de 40 páginas cuyo primer marcador de nivel superior apunta a la página 5, un divisor que corta "de este marcador al siguiente" escribe archivos de capítulo que suman 36 páginas. La portada, la página de copyright y una tabla de contenidos de dos páginas van delante de cada entrada del outline, así que nunca aterrizan en archivo alguno. Un outline solo marca dónde empiezan las secciones. No cubre lo que va antes de la primera entrada, y nada en el formato exige que las entradas estén listadas en orden de páginas, así que un divisor confiable recolecta las páginas de los marcadores como puntos de corte, las ordena y agrega un rango explícito de material inicial

Un bucle detrás de cada modo de división

PDFium Component divide un documento con los mismos cuatro pasos sobre un segundo TPdf, sea lo que sea que decida los límites: CreateDocument arranca un PDF vacío en memoria, ImportPages copia un rango de páginas del origen hacia él, SaveAs escribe el archivo, y Active := False cierra la salida de nuevo. La división página por página, los trozos de tamaño fijo y las secciones por marcador difieren solo en el string de rango que se le entrega a ImportPages

El reinicio al final de cada pasada no es opcional. CreateDocument se niega a correr sobre un componente que sigue activo y lanza un EPdfError cuyo mensaje empieza con "Cannot perform this operation on an active", así que la segunda iteración de un bucle que olvida Active := False se detiene con una excepción después de que se escribió exactamente un archivo. Poner el reinicio en un bloque finally evita que una importación o un guardado fallido deje la salida abierta para la siguiente pasada. Una instancia de TPdf sirve para toda la corrida, y solo un documento de salida se mantiene en memoria

Diagrama del bucle de división de PDFium Component en Delphi: CreateDocument, ImportPages desde el origen de solo lectura, un SaveAs verificado, y el reinicio de Active antes de cada nueva iteración
Sea lo que decida los grupos, el bucle permanece idéntico: importe las páginas, guarde con el resultado verificado, y luego restablezca Active para que el próximo CreateDocument arranque desde un estado limpio

Abrir el origen merece su propia comprobación. Asignar Active := True nunca lanza: cuando el archivo falta, está dañado o necesita una contraseña que no se suministró, TPdf atrapa el error de carga y deja Active en False. ImportPages también intenta activar el origen por su cuenta, lo que significa que un origen sin abrir se muestra solo como un retorno False en la primera página sin ninguna pista sobre la causa. Abrirlo explícitamente y probar Active convierte eso en un error que nombra el archivo antes de que exista cualquier salida:

procedure SplitIntoPages(const SourceFile, OutputDir: string);
var
  Source, PdfOut: TPdf;
  I: Integer;
  OutFile: string;
begin
  Source := TPdf.Create(nil);
  PdfOut := TPdf.Create(nil);
  try
    Source.FileName := SourceFile;
    Source.Active := True;               // una falla de carga deja Active = False
    if not Source.Active then
      raise Exception.Create('Cannot open ' + SourceFile);

    for I := 1 to Source.PageCount do
    begin
      PdfOut.CreateDocument;             // lanza mientras PdfOut sigue activo
      try
        // Range es base 1; la posición 1 es el inicio del documento vacío
        if not PdfOut.ImportPages(Source, IntToStr(I), 1) then
          raise Exception.CreateFmt('Cannot import page %d', [I]);
        OutFile := IncludeTrailingPathDelimiter(OutputDir) +
          Format('page_%.4d.pdf', [I]);
        if not PdfOut.SaveAs(OutFile) then
          raise Exception.Create('Cannot save ' + OutFile);
      finally
        PdfOut.Active := False;          // cierre la salida antes de la siguiente pasada
      end;
    end;
  finally
    PdfOut.Free;
    Source.Free;
  end;
end;

El tercer argumento de ImportPages es la posición base 1 del destino donde aterriza la primera página importada. En un documento vacío 1 es la única posición que hay. La sintaxis de rangos misma (páginas sueltas, tramos cerrados como '5-10', listas con comas) y los errores de posición que invierten un paquete fusionado están cubiertos en fusionar varios archivos PDF en un documento, que es la misma API apuntada al revés

Trozos de tamaño fijo y rangos suministrados por quien llama

Cortar un documento cada N páginas es un problema de conteo, y el único caso que necesita cuidado es la cola. Un escaneo de 230 páginas dividido en partes de 25 páginas da nueve partes completas y una décima de cinco páginas, así que el final de cada tramo se recorta a Source.PageCount:

procedure SplitEvery(Source: TPdf; ChunkSize: Integer; const OutputDir: string);
var
  PdfOut: TPdf;
  FirstPage, LastPage, Part: Integer;
  Range, OutFile: string;
begin
  if ChunkSize < 1 then
    raise Exception.Create('ChunkSize must be at least 1');
  PdfOut := TPdf.Create(nil);
  try
    Part := 0;
    FirstPage := 1;
    while FirstPage <= Source.PageCount do
    begin
      LastPage := FirstPage + ChunkSize - 1;
      if LastPage > Source.PageCount then
        LastPage := Source.PageCount;    // la última parte suele ser más corta
      if FirstPage = LastPage then
        Range := IntToStr(FirstPage)     // una cola de una página como número plano
      else
        Range := Format('%d-%d', [FirstPage, LastPage]);
      Inc(Part);

      PdfOut.CreateDocument;
      try
        if not PdfOut.ImportPages(Source, Range, 1) then
          raise Exception.Create('Cannot import pages ' + Range);
        OutFile := IncludeTrailingPathDelimiter(OutputDir) +
          Format('part_%.3d.pdf', [Part]);
        if not PdfOut.SaveAs(OutFile) then
          raise Exception.Create('Cannot save ' + OutFile);
      finally
        PdfOut.Active := False;
      end;
      FirstPage := LastPage + 1;
    end;
  finally
    PdfOut.Free;
  end;
end;

Los rangos tecleados por un usuario son otro asunto. ImportPages reporta un rango malo mediante su valor de retorno: un número de página mayor que Source.PageCount, un cero, un tramo invertido o texto que no parsea hace que la llamada devuelva False, y ninguna de las páginas de ese string se copia. Dentro de un bucle, esa falla llega en la cuarta entrada cuando ya existen tres archivos de sección en disco. Verificar cada entrada contra Source.PageCount antes del primer CreateDocument cuesta unas cuantas líneas de parseo y significa que un error de tecleo en la entrada cuatro no escribe nada, que es el comportamiento que espera quien reintenta el trabajo

Cuando la selección ya vive en un arreglo de enteros, por ejemplo páginas elegidas en una vista de miniaturas, ImportPagesByIndex(Source, PageIndices, InsertAt) se salta el paso del string. Tanto los índices como InsertAt son base cero allí, así que la misma selección que se lee '1,3-5' como rango se vuelve [0, 2, 3, 4]

¿Por qué las secciones por marcador salen cortas o faltan?

Las secciones por marcador salen mal cuando el código confía en que el outline esté ordenado. TPdf.Bookmarks devuelve las entradas de nivel superior en el orden en que el autor las arregló, cada una como un record TBookmark con un Title y un PageNumber base 1. Cuatro cosas en archivos reales rompen la regla ingenua de "esta entrada a la siguiente":

  • Un elemento del outline puede llevar su objetivo como entrada /Dest o como acción GoTo en /A (ISO 32000-1 §12.3.3 y §12.6.4.2). PageNumber se llena desde /Dest; para un elemento basado en acción queda en 0 y la página está en ActionPageNumber
  • El orden del outline es el orden de lectura tal como lo vio el autor. Una entrada "Index" colocada en segundo lugar, o un apéndice listado antes de los capítulos, hace que "el siguiente marcador menos uno" sea menor que el inicio actual, y esa sección desaparece
  • Dos entradas de nivel superior pueden apuntar a la misma página, típicamente un título de parte y su primer capítulo
  • Una entrada puede apuntar más allá de la última página cuando se borraron páginas con una herramienta que dejó el outline tranquilo
Diagrama que mapea marcadores PDF de nivel superior a rangos de páginas calculados y archivos de salida al dividir con PDFium Component en Delphi, incluido un marcador fuera de rango que se omite
Una sección corre desde la página de cada marcador de nivel superior hasta una página antes del siguiente marcador, y las entradas que apuntan más allá del final se omiten en lugar de producir archivos vacíos

La versión de abajo trata el outline como una fuente de puntos de corte. Resuelve la página de cada entrada, descarta las entradas que no apuntan a nada o que apuntan más allá del final, conserva el primer título en una página compartida, ordena por número de página y agrega una sección "Front matter" cuando el primer corte es después de la página 1. El material inicial recibe su propio archivo aquí en lugar de pegarse al capítulo 1, porque quien recibe el capítulo 1 espera que se abra en la página que el marcador nombró, no en una tabla de contenidos

procedure SplitByBookmarks(Source: TPdf; const OutputDir: string);
var
  Bm: TBookmarks;
  Starts: array of Integer;
  Titles: array of string;
  I, J, K, N, P, EndPage: Integer;
  Known: Boolean;
  PdfOut: TPdf;
  OutFile: string;
begin
  Bm := Source.Bookmarks;                // solo entradas de nivel superior
  if Length(Bm) = 0 then
    Exit;                                // nada sobre qué cortar
  SetLength(Starts, Length(Bm) + 1);     // +1 deja lugar para el material inicial
  SetLength(Titles, Length(Bm) + 1);
  N := 0;
  for I := 0 to High(Bm) do
  begin
    P := Bm[I].PageNumber;               // resuelto desde /Dest
    if P < 1 then
      P := Bm[I].ActionPageNumber;       // resuelto desde una acción GoTo en /A
    if (P < 1) or (P > Source.PageCount) then
      Continue;                          // sin resolver, o más allá de la última página
    Known := False;
    for J := 0 to N - 1 do
      if Starts[J] = P then
        Known := True;                   // el primer título de una página gana
    if Known then
      Continue;
    J := N;                              // ordenamiento por inserción por número de página
    while (J > 0) and (Starts[J - 1] > P) do
    begin
      Starts[J] := Starts[J - 1];
      Titles[J] := Titles[J - 1];
      Dec(J);
    end;
    Starts[J] := P;
    Titles[J] := Bm[I].Title;
    Inc(N);
  end;
  if N = 0 then
    Exit;
  if Starts[0] > 1 then                  // portada, TOC y cualquier cosa antes de ellos
  begin
    for J := N downto 1 do
    begin
      Starts[J] := Starts[J - 1];
      Titles[J] := Titles[J - 1];
    end;
    Starts[0] := 1;
    Titles[0] := 'Front matter';
    Inc(N);
  end;

  PdfOut := TPdf.Create(nil);
  try
    for K := 0 to N - 1 do
    begin
      if K < N - 1 then
        EndPage := Starts[K + 1] - 1
      else
        EndPage := Source.PageCount;
      PdfOut.CreateDocument;
      try
        if not PdfOut.ImportPages(Source,
          Format('%d-%d', [Starts[K], EndPage]), 1) then
          raise Exception.CreateFmt('Cannot import pages %d-%d',
            [Starts[K], EndPage]);
        //// Declarar SafeFileName (mostrado más abajo) antes de esta rutina
        OutFile := IncludeTrailingPathDelimiter(OutputDir) +
          Format('%.2d_%s.pdf', [K + 1, SafeFileName(Titles[K])]);
        if not PdfOut.SaveAs(OutFile) then
          raise Exception.Create('Cannot save ' + OutFile);
      finally
        PdfOut.Active := False;
      end;
    end;
  finally
    PdfOut.Free;
  end;
end;

Como los inicios quedan estrictamente crecientes después del ordenamiento, cada tramo calculado es válido y la guarda de ImportPages nunca debería dispararse sobre un origen legible. Un documento sin marcadores simplemente no produce archivos en este modo; quien llama decide si eso cae a la división por trozos. Para cortar un nivel más profundo, por ejemplo en capítulos dentro de partes, BookmarkChildren[Bm[I]] devuelve los hijos de una entrada de nivel superior en la misma forma TBookmarks, y la misma lógica de puntos de corte aplica a la lista combinada

Qué lleva cada archivo de salida

Cada archivo de salida lleva las páginas mismas y todo lo que sus diccionarios de página referencian: streams de contenido, fuentes, imágenes y la lista de anotaciones de cada página. Los widgets de formulario vienen como anotaciones, pero el archivo nuevo no tiene AcroForm, así que dejan de ser campos funcionales. El origen es de solo lectura. ImportPages copia objetos al documento nuevo y deja el origen abierto intacto, así que la misma instancia Source alimenta cada iteración

El outline se queda atrás. Los marcadores viven en el catálogo del documento bajo /Outlines (ISO 32000-1 §7.7.2), fuera de cualquier página, así que un archivo construido a partir de páginas importadas empieza sin marcador alguno. Para archivos de capítulos eso suele estar bien. Cuando cada parte necesita su propia navegación, el outline tiene que construirse para la nueva numeración de páginas, que es el tema de editar un outline PDF y remapear sus páginas. Los enlaces internos se comportan igual: un enlace "vea el capítulo 7" en la página 12 apunta a una página que fue a parar a otro archivo, y en este archivo no tiene dónde aterrizar

Los orígenes cifrados necesitan la contraseña antes de que empiece el bucle. Asigne Source.Password mientras el componente está inactivo, y luego ponga Active := True; con la contraseña equivocada el componente se queda inactivo, lo que la comprobación de SplitIntoPages convierte en un error. La salida que SaveAs escribe es un documento nuevo sin diccionario de cifrado. Eso conviene a los archivos divididos pensados para destinatarios distintos, y cuando cada parte tiene que protegerse de nuevo, SaveAsEncrypted(FileName, Options) recibe un record TPdfEncryptOptions con sus propias contraseñas de usuario y propietario

Nombres de archivo, guardados fallidos y reejecuciones

Los títulos de marcadores son cadenas de texto PDF y pueden contener cualquier cosa, incluidos caracteres que Windows rechaza en nombres de archivo. Las reglas vienen del sistema de archivos, no del PDF: < > : " / \ | ? * y los caracteres de control son inválidos, los espacios y puntos finales se recortan por las APIs de archivo Win32, y nombres como CON, PRN o COM1 son dispositivos reservados. El prefijo numérico de SplitByBookmarks ya se encarga de los nombres de dispositivo, mantiene los archivos en orden de lectura en un listado de directorio, y evita que dos capítulos ambos titulados "Summary" se sobrescriban entre sí. El resto es una función pequeña que usted escribe:

//// Helper del lado del lector, no forma parte de PDFium Component
function SafeFileName(const Title: string): string;
const
  Forbidden = '<>:"/\|?*';
var
  I: Integer;
begin
  Result := Title;
  if Length(Result) > 80 then
    SetLength(Result, 80);               // mantenga las rutas completas bien por debajo de MAX_PATH
  for I := 1 to Length(Result) do
    if (Ord(Result[I]) < 32) or (Pos(Result[I], Forbidden) > 0) then
      Result[I] := '_';
  while (Result <> '') and
    ((Result[Length(Result)] = '.') or (Result[Length(Result)] = ' ')) do
    SetLength(Result, Length(Result) - 1);
  if Result = '' then
    Result := 'section';
end;

SaveAs con un nombre de archivo escribe a un archivo temporal en la carpeta de destino y lo intercambia a su lugar solo después de que el guardado tuvo éxito. El destino es por tanto el archivo anterior o uno nuevo completo, nunca a medio escribir, así que reejecutar una división sobre una carpeta de salida existente es inofensivo. Las fallas regresan de dos maneras. SaveAs devuelve False cuando el documento no se puede serializar, y el archivo temporal se elimina. Los problemas del sistema de archivos, como una carpeta de salida que no existe, una ruta que el OS rechaza o un objetivo bloqueado por un visor, lanzan una excepción desde la capa de archivos

Cómo reacciona un lote ante esas fallas es una elección, y para dividir difiere de fusionar. Un paquete fusionado con una parte faltante es un documento equivocado, así que detenerse es lo correcto allí. Las salidas divididas son independientes entre sí: si section_147.pdf no se puede escribir porque un colega lo tiene abierto, las otras secciones siguen siendo buenas. Para trabajos de división, atrape las excepciones por archivo dentro del bucle, registre el nombre del archivo y el mensaje, continúe, y compare el número de archivos escritos con el número de rangos planeados cuando el bucle termine. Los ejemplos de arriba lanzan en la primera falla solo para quedarse cortos. Un registro que dice que se escribieron 199 de 200 archivos y nombra el que estaba bloqueado toma una reejecución para arreglarse, mientras que una corrida que abortó en el archivo 147 deja a alguien averiguando cuáles 53 archivos faltan

El bucle de división, los records de marcadores y el guardado cifrado usados aquí vienen todos con el PDFium Component, la biblioteca PDF basada en PDFium para Delphi y C++Builder