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, escaneando hacia afuera desde el carácter clicado hasta que se excede la tolerancia. Cada ruta de selección en el visor llama a ese único ayudante, así que el mouse, 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 en un informe de 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 en el 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 para seleccionar, y cualquier implementación que finja lo contrario está adivinando. Este artículo trata sobre hacer la suposición deliberada y hacerla consistente. Si lo que realmente necesitas es extraer texto de un documento, consulta extraer texto de documentos PDF con PDFium; si estás maquetando texto y necesitas anchos, consulta medición de texto y ajuste de línea. Aquí el tema es más estrecho: decidir dónde comienza 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. ISO 32000-1 §9.4 define un objeto de texto como un par BT / ET que contiene operadores de posicionamiento y de mostrado. Los operadores de posicionamiento de §9.4.2 (Td, TD, Tm, T*) mueven una matriz de texto por la página, y los operadores de mostrado de §9.4.3 (Tj, TJ, ', ") pintan glifos donde sea que esa matriz esté apuntando actualmente. Nada en ese modelo dice "esta serie de glifos es una línea". Una línea es lo que un humano ve 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 arreglo TJ por línea, o como un Tj por palabra con una Tm explícita antes de cada una, o como una sola operación de mostrado con ajustes de kerning llevando el espaciado. Un diseño de dos columnas puede emitir la columna izquierda de arriba a abajo y luego la columna derecha, o puede entrelazarlas si el productor recorrió su propia lista de objetos interna en un orden diferente. La secuencia de caracteres que PDFium te entrega sigue el flujo de contenido, y el flujo de contenido sigue lo que sea que la aplicación generadora sintió hacer. Así que las dos funciones que realmente obtienes son FPDFText_CountChars, que reporta cuántos caracteres tiene la página, y FPDFText_GetCharBox, que devuelve el cuadro delimitador de un carácter en espacio de página. Ese es todo el vocabulario crudo. 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 confiable, y cuando están presentes no son confiablemente 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 series están visualmente separadas, un CR o LF donde la siguiente serie comienza en una nueva línea base. 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 sobre esos caracteres y heredas cada juicio que PDFium hizo al sintetizarlos. Un salto de línea duro dentro de un párrafo ajustado y un ajuste suave se ven idénticos después de la síntesis. Una fila de tabla que el productor emitió celda por celda puede no obtener ningún salto en absoluto entre la última celda y la primera celda de la siguiente fila, porque las líneas base resultan estar suficientemente cerca. Mientras tanto, un encabezado seguido de texto de cuerpo en un tamaño diferente puede obtener dos saltos donde un humano ve uno. Los caracteres generados son una conveniencia de renderizado para extracción de página completa; no son un modelo de línea, y se degradan exactamente en los documentos donde la selección más importa

Agrupar cajas de carácter por centro vertical

La señal confiable es la geometría. Toma el carácter que el usuario clicó como semilla, calcula el centro vertical de su caja, y camina hacia afuera en ambas direcciones mientras las cajas vecinas mantienen sus centros verticales dentro de la tolerancia. PDFium Component usa la mitad de la altura de la caja semilla como esa tolerancia, con un piso de 0.5 unidades de página para que las cajas degeneradas, un punto, un espacio delgado, 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 en ese bucle se ganan su lugar. La tolerancia se deriva de la semilla en vez de una constante, así que un encabezado de 24pt obtiene una banda ancha y texto de nota al pie de 7pt obtiene una estrecha, y ninguno roba caracteres del otro. La comparación usa centros verticales en vez de líneas base o partes superiores de caja, lo que mantiene un superíndice, una serie de tamaño diferente en línea, o una oración de fuente mixta en la misma línea que sus vecinos. Y un FPDFText_GetCharBox fallido termina el escaneo en vez de saltarse, porque un carácter sin geometría recuperable no te da evidencia en ningún sentido, y continuar más allá de él permitiría que el recorrido salte a través de un límite genuino con la fuerza de un carácter más adelante

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

Porque tres rutas de código que cada una implemente "la línea" divergirán, y divergirán silenciosamente. En PDFium Component, la expansión de triple clic, Shift+Home, Shift+End, y el método público SelectLineAt todos resuelven sus límites a través de la misma llamada LineRangeAt. El triple clic la siembra desde el ancla de selección; las teclas shift la siembran desde el cursor de selección y mueven solo ese extremo; SelectLineAt la siembra desde un índice de carácter suministrado por quien llama y entrega el resultado a SelectTextRange, el mismo validador de rango que usa la ruta del mouse. Duplica la lógica en su lugar y el fallo no es un choque, es una deriva lenta. Alguien ajusta la tolerancia de triple clic para arreglar un informe con interlineado apretado, y ahora Shift+End se detiene un carácter corto de donde se detiene el triple clic en el mismo párrafo. Un usuario selecciona una línea con el mouse, la extiende con el teclado, y ve la selección encogerse. Porque SelectLineAt alimenta el pipeline de selección ordinario, la selección programática también permanece independiente de si la entrada de mouse está habilitada, y todavía obtiene validación de rango, repintado, y la notificación OnSelectionChange gratis

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

Nota los argumentos de tolerancia en CharacterIndexAtPos. La prueba de impacto tiene 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 más cercano dentro de esa caja; el escaneo de línea luego se ejecuta desde el carácter que resultó ser. 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 la tentación 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 usados por FPDFText_GetCharBox y FPDFText_CountChars. Los caracteres generados discutidos antes se sientan en el búfer de texto mientras ocupan ranuras de carácter sin geometría utilizable, y las dos numeraciones se van separando a través 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 pasar esos directamente a una API de caja o selección direcciona silenciosamente los caracteres equivocados, con un error que crece cuanto más abajo vas en la página. Convierte con TextIndexToCharacterIndex antes de que algo geométrico toque el número. Los pares sustitutos agregan 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 la página, y el escaneo se detiene casi de inmediato. Lo que obtienes es una selección corta en vez de una equivocada, que es el mejor modo de fallo, pero sigue siendo un fallo. Los modos de escritura vertical se comportan de la misma manera por la misma razón. Los diseños de dos columnas funcionan cuando las columnas están verticalmente desplazadas entre sí y se rompen cuando no lo están. Si ambas columnas comparten una cuadrícula de línea base, los caracteres de la columna derecha se sientan dentro de la tolerancia de la línea de la columna izquierda, y el escaneo correrá directamente a través del canal, porque en geometría pura no hay nada ahí que lo detenga. Detectar eso necesita una prueba de brecha horizontal encima del agrupamiento vertical, y elegir el umbral de brecha es su propio juicio sobre en qué documentos estás dispuesto a estar equivocado. 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 texto de cuerpo de 11pt mantiene su centro dentro de la banda, y un encabezado de 24pt en la siguiente línea base no arrastra la línea de cuerpo hacia sí mismo

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