Artículo técnico

Selección de línea de texto PDF con cajas de carácter PDFium en Delphi

Una página de texto PDF expone caracteres y cajas, nunca líneas. PDFium Component construye una línea visual agrupando cajas de carácter cuyos centros verticales caen dentro de la mitad de la altura del carácter semilla, explorando hacia fuera desde el carácter pulsado hasta que se supera la tolerancia. Cada ruta de selección en el visor llama a ese único ayudante, así que el ratón, el teclado y el código coinciden

El síntoma que te trae a buscar esto es específico y desagradable. Un usuario hace triple clic en un párrafo de un informe a dos columnas y obtiene media página. O hace triple clic en una celda de tabla y la selección se traga toda la fila más el número de página del pie. El visor no está roto; está respondiendo a una pregunta que el archivo no puede responder. No hay ninguna línea en un PDF que seleccionar, y cualquier implementación que finja lo contrario está adivinando. Este artículo trata sobre hacer que la conjetura sea deliberada y hacer que sea coherente. Si lo que realmente necesitas es extraer texto de un documento, consulta extraer texto de documentos PDF con PDFium; si estás disponiendo texto y necesitas anchos, consulta medición de texto y ajuste de línea. Aquí el tema es más estrecho: decidir dónde empieza y termina una línea visual, y seleccionar exactamente eso

¿Por qué una página de texto PDF no tiene objetos de línea?

Porque un flujo de contenido PDF describe dibujo, no estructura. La norma ISO 32000-1 §9.4 define un objeto de texto como un par BT / ET que contiene operadores de posicionamiento y de presentación. Los operadores de posicionamiento del §9.4.2 (Td, TD, Tm, T*) mueven una matriz de texto por la página, y los operadores de presentación del §9.4.3 (Tj, TJ, ', ") pintan glifos dondequiera que apunte esa matriz en ese momento. Nada en ese modelo dice "esta tirada de glifos es una línea". Una línea es lo que ve un humano después de que el pintado ha terminado

Los productores empeoran esto de formas que no puedes controlar. Un párrafo justificado puede emitirse como un array TJ por línea, o como un Tj por palabra con un Tm explícito antes de cada una, o como una única operación de presentación con ajustes de kerning que llevan el espaciado. Un diseño a dos columnas puede emitir la columna izquierda de arriba abajo y luego la derecha, o puede entrelazarlas si el productor recorrió su propia lista interna de objetos en un orden distinto. La secuencia de caracteres que te entrega PDFium sigue el flujo de contenido, y el flujo de contenido sigue lo que le apeteciera hacer a la aplicación generadora. Así que las dos funciones que realmente obtienes son FPDFText_CountChars, que informa de cuántos caracteres contiene la página, y FPDFText_GetCharBox, que devuelve el rectángulo delimitador de un carácter en espacio de página. Ese es todo el vocabulario en bruto. Todo lo que hay por encima, palabras, líneas, párrafos, columnas, es inferencia que tú realizas sobre la geometría

¿Por qué la detección de CR y LF es la prueba equivocada?

Porque los caracteres contra los que probarías no están presentes de forma fiable, y cuando están presentes no son fiablemente tuyos. PDFium inyecta caracteres sintéticos en la página de texto para hacer que el texto extraído sea legible: un espacio donde dos tiradas están visualmente separadas, un CR o LF donde la siguiente tirada empieza en una línea de base nueva. FPDFText_IsGenerated existe precisamente para que puedas distinguirlos de los caracteres que salieron del archivo, y PDFium Component lo expone como la propiedad CharacterGenerated

Divide por esos caracteres y heredas cada decisión que tomó PDFium al sintetizarlos. Un salto de línea forzado dentro de un párrafo con ajuste automático y un ajuste suave se ven idénticos tras la síntesis. Una fila de tabla que el productor emitió celda por celda puede no recibir ningún salto entre la última celda y la primera celda de la siguiente fila, porque las líneas de base resultan estar lo bastante cerca. Mientras tanto, un encabezado seguido de texto de cuerpo a un tamaño distinto puede recibir dos saltos donde un humano ve uno. Los caracteres generados son una comodidad de renderizado para la extracción de página completa; no son un modelo de línea, y se degradan exactamente en los documentos donde más importa la selección

Agrupar cajas de carácter por centro vertical

La señal fiable es la geometría. Toma el carácter que pulsó el usuario como semilla, calcula el centro vertical de su caja, y avanza hacia fuera en ambas direcciones mientras las cajas vecinas mantengan sus centros verticales dentro de la tolerancia. PDFium Component usa la mitad de la altura de la caja semilla como esa tolerancia, con un mínimo de 0.5 unidades de página para que las cajas degeneradas, un punto, un espacio fino, un glifo con una caja de altura casi cero, no colapsen la tolerancia a nada y corten la línea después de un carácter

function TPdfView.LineRangeAt(TxtPage: FPDF_TEXTPAGE; CharIndex: Integer;
  out StartIndex, Count: Integer): Boolean;
var
  Lo, Hi, Total: Integer;
  SeedBox, Box: TPdfRectangle;
  SeedYMid, BoxYMid, HalfH: Double;
begin
  Result := False;
  StartIndex := -1;
  Count := 0;
  Total := FPDFText_CountChars(TxtPage);
  if (CharIndex < 0) or (CharIndex >= Total) then
    Exit;

  if FPDFText_GetCharBox(TxtPage, CharIndex, SeedBox.Left, SeedBox.Right,
    SeedBox.Bottom, SeedBox.Top) = 0 then
    Exit;
  SeedYMid := (SeedBox.Top + SeedBox.Bottom) / 2;
  HalfH := Abs(SeedBox.Top - SeedBox.Bottom) / 2;
  if HalfH < 0.5 then          // floor for degenerate boxes
    HalfH := 0.5;

  Lo := CharIndex;
  Hi := CharIndex;
  while Lo > 0 do
  begin
    if FPDFText_GetCharBox(TxtPage, Lo - 1, Box.Left, Box.Right,
      Box.Bottom, Box.Top) = 0 then
      Break;
    BoxYMid := (Box.Top + Box.Bottom) / 2;
    if Abs(BoxYMid - SeedYMid) > HalfH then
      Break;
    Dec(Lo);
  end;
  while Hi < Total - 1 do
  begin
    if FPDFText_GetCharBox(TxtPage, Hi + 1, Box.Left, Box.Right,
      Box.Bottom, Box.Top) = 0 then
      Break;
    BoxYMid := (Box.Top + Box.Bottom) / 2;
    if Abs(BoxYMid - SeedYMid) > HalfH then
      Break;
    Inc(Hi);
  end;
  StartIndex := Lo;
  Count := Hi - Lo + 1;
  Result := True;
end;

Tres detalles de ese bucle se ganan su lugar. La tolerancia se deriva de la semilla en lugar de una constante, así que un encabezado de 24pt obtiene una banda ancha y un texto de nota al pie de 7pt obtiene una estrecha, y ninguno le roba caracteres al otro. La comparación usa centros verticales en lugar de líneas de base o partes superiores de caja, lo que mantiene un superíndice, una tirada de tamaño distinto en línea, o una frase con fuentes mixtas en la misma línea que sus vecinos. Y un FPDFText_GetCharBox fallido termina la exploración en lugar de saltarse, porque un carácter sin geometría recuperable no te da ninguna evidencia en ningún sentido, y continuar más allá de él dejaría que el recorrido saltara a través de un límite genuino apoyándose en un carácter más lejano

¿Por qué cada ruta de selección debe compartir un único ayudante?

Porque tres rutas de código que implementen cada una "la línea" divergirán, y divergirán silenciosamente. En PDFium Component, la expansión por triple clic, Shift+Home, Shift+End, y el método público SelectLineAt resuelven todos sus límites mediante la misma llamada a LineRangeAt. El triple clic la alimenta desde el ancla de selección; las teclas shift la alimentan desde el cursor de selección y mueven solo ese extremo; SelectLineAt la alimenta desde un índice de carácter suministrado por el llamador y entrega el resultado a SelectTextRange, el mismo validador de rango que usa la ruta del ratón. Duplica la lógica en su lugar y el fallo no es un fallo catastrófico, es una deriva lenta. Alguien ajusta la tolerancia del triple clic para arreglar un informe con interlineado apretado, y ahora Shift+End se detiene un carácter antes de donde se detiene el triple clic en el mismo párrafo. Un usuario selecciona una línea con el ratón, la extiende con el teclado, y ve cómo se encoge la selección. Como SelectLineAt alimenta el pipeline de selección ordinario, la selección programática también permanece independiente de si la entrada por ratón está activada, y sigue obteniendo validación de rango, repintado, y la notificación OnSelectionChange de forma gratuita

// Select the visual line under a client-space point, then read it back
procedure TForm1.SelectLineUnderCursor(X, Y: Integer);
var
  CharIndex: Integer;
begin
  CharIndex := PdfView1.CharacterIndexAtPos(X, Y, 6.0, 6.0);
  if CharIndex < 0 then
    Exit;
  if PdfView1.SelectLineAt(PdfView1.CurrentPage, CharIndex) then
    Memo1.Lines.Add(PdfView1.SelectedText);
end;

Nótense los argumentos de tolerancia en CharacterIndexAtPos. Las pruebas de impacto tienen su propio margen, expresado en unidades de página, y es una preocupación separada de la tolerancia de línea. Un clic que aterriza en el interlineado entre dos líneas se resuelve al carácter que quede más cerca dentro de esa caja; la exploración de línea entonces se ejecuta desde el carácter que resultara ser ese. Alimentar una tolerancia de impacto demasiado generosa en la semilla es una de las formas más fáciles de seleccionar una línea a la que el usuario no estaba apuntando

Dos espacios de índice: índice de carácter e índice de texto

Una vez que tienes un rango, resiste el impulso de usarlo como un desplazamiento de cadena. FPDFText_GetText devuelve el texto de la página como un búfer UTF-16, pero sus índices no son el mismo espacio de índice que los índices de carácter que usan FPDFText_GetCharBox y FPDFText_CountChars. Los caracteres generados de los que se habló antes se sitúan en el búfer de texto mientras ocupan ranuras de carácter sin geometría utilizable, y las dos numeraciones se van separando a lo largo de la página. Los puentes son FPDFText_GetTextIndexFromCharIndex y FPDFText_GetCharIndexFromTextIndex, envueltos por PDFium Component como CharacterIndexToTextIndex y TextIndexToCharacterIndex

var
  TextStart, TextEnd: Integer;
begin
  // char-index range from LineRangeAt -> offsets into the page text buffer
  TextStart := Pdf.CharacterIndexToTextIndex(StartIndex);
  TextEnd   := Pdf.CharacterIndexToTextIndex(StartIndex + Count - 1);
  if (TextStart >= 0) and (TextEnd >= TextStart) then
    Caption := Pdf.Text(TextStart, TextEnd - TextStart + 1);
end;

La dirección que más muerde es la inversa. Una búsqueda implementada sobre la cadena extraída te da índices de texto, y pasarlos directamente a una API de caja o selección direcciona silenciosamente los caracteres equivocados, con un error que crece cuanto más abajo vayas en la página. Convierte con TextIndexToCharacterIndex antes de que nada geométrico toque el número. Los pares sustitutos añaden un segundo problema de desplazamiento, independiente, encima de esto, que se cubre en el artículo sobre emoji, CJK y pares sustitutos

Dónde se dobla la heurística

Sé honesto contigo mismo sobre los límites, porque son reales y son alcanzables. El texto rotado es el caso más claro: una caja de carácter es un rectángulo alineado con los ejes en espacio de página, así que para texto rotado 90 grados las cajas de una línea visual tienen centros verticales dispersos por toda la página, y la exploración se detiene casi de inmediato. Lo que obtienes es una selección corta en lugar de una equivocada, que es el mejor modo de fallo, pero sigue siendo un fallo. Los modos de escritura vertical se comportan igual por el mismo motivo. Los diseños a dos columnas funcionan cuando las columnas están desplazadas verticalmente entre sí y se rompen cuando no lo están. Si ambas columnas comparten una cuadrícula de línea de base, los caracteres de la columna derecha caen dentro de la tolerancia de la línea de la columna izquierda, y la exploración correrá directamente a través del medianil, porque en geometría pura no hay nada ahí que la detenga. Detectar eso necesita una prueba de brecha horizontal encima de la agrupación vertical, y elegir el umbral de brecha es en sí mismo una decisión sobre en qué documentos estás dispuesto a equivocarte. Los tamaños de fuente mixtos son el caso que la tolerancia relativa a la semilla maneja bien: un fragmento de código de 8pt en línea dentro de un texto de cuerpo de 11pt mantiene su centro dentro de la banda, y un encabezado de 24pt en la siguiente línea de base no atrae la línea de cuerpo hacia sí

La semántica de selección de línea descrita aquí se incluye en PDFium Component para Delphi y C++Builder, junto con las API de prueba de impacto, rango de selección e índice de texto usadas en los ejemplos; la página del producto lleva la referencia completa del modelo de página de texto y selección