HotPDF incluye THPDFBuiltInOCREngine, un motor OCR acotado por coincidencia de plantillas escrito enteramente en Object Pascal: binariza una página renderizada con el umbral de Otsu, extrae los glifos como componentes conexos y puntúa cada glifo mediante la cobertura en escala de grises frente a plantillas almacenadas de varias fuentes, para que una aplicación Delphi pueda crear una capa de texto buscable sin depender de un OCR externo. El motor tuvo que reconstruirse desde cero en v2.731.0, y el motivo no era el matcher. Eran los píxeles
El motor antiguo superaba sus pruebas. Reconocía ASCII en mayúsculas sobre mapas de bits sintéticos y en Win32 siguió haciéndolo durante meses. Después se ejecutó el mismo código bajo Win64 y no produjo absolutamente nada: ni palabras, ni diagnóstico aparte de «found no high-contrast foreground», ni un crash. El bug resultó ser la combinación de dos errores independientes en la ruta de lectura de píxeles que se estaban cancelando entre sí, y deshacerlos es una buena muestra de por qué el código OCR falla en silencio en lugar de hacerlo de forma estruendosa
¿Por qué el motor OCR antiguo solo funcionaba por accidente?
El motor antiguo funcionaba porque sus mapas de bits de plantilla y sus mapas de bits objetivo estaban invertidos de la misma manera, de modo que una inversión vertical en el lector de píxeles era invisible para el matcher. TBitmap.ScanLine devuelve las filas en el orden contrario al de la convención DIB con biHeight positivo que asume el resto de la ruta de imagen. Renderice una M boca abajo, compárela con una plantilla también invertida y la diferencia L1 será idéntica a la comparación correcta. Todos los glifos coincidían. Nada estaba bien
Esa simetría es exactamente lo que encarece esta clase de bug. Cualquier corrección unilateral rompe la coincidencia: corrija la lectura del objetivo y deje las plantillas intactas, y el reconocimiento se convierte en ruido; corrija primero las plantillas y obtendrá el mismo colapso desde el otro lado. No hay una ruta de reparación incremental. Por eso la reconstrucción sustituyó toda la lectura por GetDIBits contra un BITMAPINFOHEADER declarado explícitamente, donde un biHeight positivo significa por contrato filas de abajo arriba y no por convención de VCL, e invierte una sola vez, deliberadamente, al copiar al buffer de escala de grises
El segundo error solo salió a la luz en Win64. El HDC que se pasa a GetDIBits no debe ser el propio DC de memoria del bitmap, porque el bitmap ya está seleccionado en él y Windows documenta esa situación como no válida. Pasar Bitmap.Canvas.Handle era tolerado por el proceso Win32 y fallaba de forma constante en el proceso de pruebas Win64. La corrección es un DC de pantalla desechable obtenido mediante GetDC(0), liberado en un bloque finally, que no tiene ninguna relación con bitmap alguno
procedure BitmapToGray(Bitmap: TBitmap; out Gray: TBytes);
var
Work: TBitmap;
Info: TBitmapInfo;
Buffer: TBytes;
DC: HDC;
P: PByte;
Stride, X, Y: Integer;
begin
Work := TBitmap.Create;
try
Work.Assign(Bitmap);
Work.PixelFormat := pf24bit;
Stride := ((Work.Width * 24 + 31) div 32) * 4;
SetLength(Buffer, Stride * Work.Height);
FillChar(Info, SizeOf(Info), 0);
Info.bmiHeader.biSize := SizeOf(BITMAPINFOHEADER);
Info.bmiHeader.biWidth := Work.Width;
Info.bmiHeader.biHeight := Work.Height; // positivo => filas de abajo arriba
Info.bmiHeader.biPlanes := 1;
Info.bmiHeader.biBitCount := 24;
Info.bmiHeader.biCompression := BI_RGB;
DC := GetDC(0); // nunca Work.Canvas.Handle: Work está seleccionado allí
if DC = 0 then
raise EInvalidOperation.Create('Recognition bitmap pixels could not be read');
try
if GetDIBits(DC, Work.Handle, 0, Work.Height,
@Buffer[0], Info, DIB_RGB_COLORS) <> Work.Height then
raise EInvalidOperation.Create('Recognition bitmap pixels could not be read');
finally
ReleaseDC(0, DC);
end;
SetLength(Gray, Work.Width * Work.Height);
for Y := 0 to Work.Height - 1 do
begin
P := @Buffer[(Work.Height - 1 - Y) * Stride]; // una única inversión deliberada
for X := 0 to Work.Width - 1 do
Gray[Y * Work.Width + X] :=
(Integer(P[X * 3]) * 29 + Integer(P[X * 3 + 1]) * 150 +
Integer(P[X * 3 + 2]) * 77) shr 8;
end;
finally
Work.Free;
end;
end;
Binarización y componentes conexos: de píxeles grises a cajas de glifo
HotPDF binariza primero con el método de Otsu y solo recurre a un umbral de ventana local cuando Otsu no es aplicable. La ruta global exige un histograma realmente bimodal: el motor calcula el máximo de la varianza entre clases y, además, exige que el rango de grises abarque al menos 64 niveles antes de confiar en el resultado. Un escaneo desvaído, una página con fondo degradado o un bitmap casi completamente lleno de tinta suspenden esa prueba. El fallback compara entonces cada píxel con la media de una ventana de 31 por 31, con un sesgo de 6 niveles de gris, calculada mediante sumas de columnas acumuladas para que la ventana deslizante siga siendo lineal respecto al número de píxeles
La extracción de glifos es un etiquetado de componentes 8-conexos sobre la máscara resultante, con una pila explícita en lugar de recursión, porque una máscara de página completa puede agotar sin problemas la pila de un hilo Delphi durante un flood fill profundo. En el momento del etiquetado se ejecutan dos filtros: los componentes menores de 9 píxeles se descartan como ruido de speckle, y cualquier componente que abarque más de tres quintas partes tanto de la anchura como de la altura de la imagen se descarta como marco o línea en lugar de como glifo. Una segunda pasada fusiona cajas apiladas verticalmente cuya superposición horizontal sea al menos una cuarta parte de la caja más estrecha, lo que vuelve a unir el punto de una i o una j con su asta. Todo esto funciona sobre un raster, y el raster procede del mismo renderer descrito en el renderizado de una página PDF cargada a un bitmap en Delphi, algo importante por un motivo práctico: la calidad del OCR está limitada por la calidad del renderizado, y los 300 DPI predeterminados de la capa de texto son un compromiso deliberado, no un máximo
¿Qué hace que la I mayúscula y la l minúscula sean indecidibles?
En Arial, la I mayúscula y la l minúscula se rasterizan como barras idénticas píxel a píxel, así que ninguna característica de forma puede separarlas y el caso tiene que proceder de otro sitio completamente distinto. La respuesta del motor es un clustering de alturas a nivel de línea. Las cajas de glifo se agrupan en líneas de texto por superposición vertical, se analiza en cada línea su altura de mayúscula y su baseline modal, y las alturas de una línea se dividen en un cluster corto y otro alto. Una barra del cluster corto es una l; la misma barra en el cluster alto es una I
La implementación obvia de esa separación es un umbral de proporción fijo, y no funciona. La relación entre la altura x y la altura de mayúscula de Arial es aproximadamente 0,72, justo sobre los valores 0,70 y 0,75 a los que todo el mundo recurre primero. Mueva la constante una centésima en cualquier dirección y todo un corpus cambia de caso. HotPDF utiliza en su lugar una separación unidimensional k=2 que minimiza la varianza: ordena las alturas candidatas, prueba cada punto de corte y conserva el corte cuya suma de desviaciones cuadráticas dentro de los clusters sea menor. El umbral pasa a ser una propiedad de la página y no una constante del código fuente
// ClusterHeights está ordenado de forma ascendente; buscar la división k=2 con menor varianza
BestSplit := 1;
BestVariance := 1E18;
for I := 1 to ClusterCount - 1 do
begin
SumA := 0;
for J := 0 to I - 1 do SumA := SumA + ClusterHeights[J];
SumB := 0;
for J := I to ClusterCount - 1 do SumB := SumB + ClusterHeights[J];
MeanA := SumA / I;
MeanB := SumB / (ClusterCount - I);
Variance := 0;
for J := 0 to I - 1 do
Variance := Variance + Sqr(ClusterHeights[J] - MeanA);
for J := I to ClusterCount - 1 do
Variance := Variance + Sqr(ClusterHeights[J] - MeanB);
if Variance < BestVariance then
begin
BestVariance := Variance;
BestSplit := I;
end;
end;
// solo la relación entre las medias de los dos clusters decide cuál es la banda corta
if SmallMean / TallMean <= 0.80 then
SmallGroup := ggSmall // una banda real de altura x: formas minúsculas
else
SmallGroup := ggTall; // una banda de altura: todo está en mayúsculas
Line.LowercaseContext := (SmallGroup = ggSmall);
Las líneas con una sola banda de altura no contienen ninguna evidencia interna. Un encabezado todo en mayúsculas y un pie todo en minúsculas parecen iguales de forma aislada. Para ellas, HotPDF compara la altura mediana de la línea con la altura x mediana de la página, obtenida de las líneas que sí se dividieron: una proporción igual o inferior a 1,10 marca la línea como contexto de minúsculas, una igual o superior a 1,18 como contexto de mayúsculas y cualquier valor intermedio queda sin restricciones. La coincidencia aplica después una pequeña bonificación de preferencia de caso de 0,03 hacia el candidato que coincide con ese contexto, lo que resuelve empates sin imponerse nunca a una diferencia clara de forma
¿Por qué una plantilla de 12x18 confundía c y o?
La rejilla de plantillas se amplió de 12 por 18 celdas a 16 por 24 porque, con la resolución menor, el margen de cobertura en escala de grises entre c y o caía por debajo de 0,007, muy dentro del umbral de ambigüedad del motor. Cada caja de glifo se remuestrea en la rejilla como valores de cobertura de 0 a 255 en lugar de como una máscara binaria, así que una celda con un tercio de tinta se lee aproximadamente como 85 en lugar de redondearse a negro o blanco. En 12 por 18, el lado abierto de una c apenas ocupa algo más de una columna de celdas y el promedio antialiasing borra el hueco. En 16 por 24 el hueco sobrevive al remuestreo y la mayoría de las parejas fáciles de confundir vuelven a una distancia segura
La puntuación es la distancia L1 normalizada entre las dos rejillas de cobertura, más una penalización de 0,30 veces la diferencia logarítmica de la relación de aspecto y de 0,16 veces la diferencia de densidad de tinta, con un prefilter duro que salta cualquier plantilla cuya relación de aspecto difiera en más de un factor 2,6. Las plantillas se rasterizan una vez por proceso a partir de cinco fuentes del sistema (Arial, Times New Roman, Courier New, Tahoma y Segoe UI) sobre un alfabeto de 62 caracteres, se almacenan tras una sección crítica y se reutilizan en cada llamada posterior
La última constante es la interesante. Cuando la puntuación del carácter que queda segundo está a menos de 0,018 de la ganadora, HotPDF limita la confianza del glifo a 0,5, por debajo del umbral de aceptación de 0,55, y el glifo simplemente no se emite. Es un corte fail-closed deliberado, no un artefacto de ajuste: un motor acotado que adivina produce una capa buscable cuyo texto no coincide con la imagen, y una palabra incorrecta en una capa de texto es peor que una ausente porque resulta invisible para quien revisa el escaneo
Separar palabras sin un umbral de espacio fijo
HotPDF obtiene el umbral de espacio entre palabras por línea a partir de la distribución de los huecos entre glifos, no de un múltiplo fijo de la anchura media del glifo. La heurística clásica, «un hueco mayor que 0,75 del avance medio es un espacio», se rompe en cuanto una línea mezcla dígitos con letras estrechas, porque el avance medio deja de describir algo real. El motor ordena en su lugar los huecos de la línea y busca el mayor salto entre valores consecutivos ordenados, que es la frontera entre el cluster intrapalabra y el cluster entre palabras cuando existe. Tres protecciones impiden que se active por ruido: el salto debe ser al menos 0,22 de la anchura media del glifo, el primer hueco por encima de la separación debe ser al menos 0,32 de ella y el último hueco por debajo no debe superar 0,65 de ella. Si falla cualquier protección, el umbral permanece en MaxInt y toda la línea se convierte en una sola palabra. Esta última protección impide que una pareja de kerning inusualmente ancha divida una palabra en dos, un error mucho más dañino que fusionar dos palabras, ya que un token fusionado sigue conteniendo los caracteres correctos en el orden correcto para una búsqueda de subcadena
Escribir la capa de texto invisible sobre la imagen escaneada
ApplyLoadedOCRTextLayer convierte las palabras reconocidas en una capa buscable dibujándolas con el modo de renderizado de texto 3, el modo ni-relleno-ni-trazo definido en ISO 32000-1 §9.3.6, situada sobre la imagen escaneada de la que proceden. El content stream comienza con BT seguido de 3 Tr, y cada palabra se coloca con una matriz de texto construida a partir de su baseline, su altura de mayúscula convertida desde píxeles a los DPI solicitados y una escala horizontal que estira la ejecución de glifos sintéticos hasta la anchura medida de la palabra. El resultado se copia y se busca como texto, pero no pinta nada
Existe un overload sin engine que instancia el reconocedor integrado por usted, y es el que deberían utilizar la mayoría de los callers de la ruta integrada. El reconocimiento, la validación Unicode, la contabilidad del presupuesto y la construcción del contenido terminan antes de abrir la transacción copy-on-write, por lo que una cancelación, un exceso de presupuesto o un fallo del engine dejan intactos el grafo de objetos y el número de versión. Las palabras se filtran dos veces: el engine descarta todo lo que está por debajo de su propia puerta de confianza por glifo de 0,55, y después THPDFOCRTextLayerOptions.MinimumConfidence (0,5 por defecto) descarta las palabras completas por debajo del umbral del caller
var
Doc: THotPDF;
Options: THPDFOCRTextLayerOptions;
Info: THPDFOCRTextLayerInfo;
begin
Doc := THotPDF.Create(nil);
try
Doc.AutoLaunch := False;
if Doc.LoadFromFile('scan.pdf') < 1 then
Exit;
Options := THPDFOCRTextLayerOptions.Default; // DPI 300, MinimumConfidence 0.5
Options.SkipPagesWithText := True; // dejar intactas las páginas nacidas digitales
Options.UseOptionalContentGroup := True;
Options.OptionalContentGroupName := 'OCR Text Layer';
// overload sin engine: HotPDF proporciona el reconocedor acotado integrado
if Doc.ApplyLoadedOCRTextLayer([0], Options, Info) then
begin
Writeln(Info.AcceptedWordCount, ' words accepted by ',
string(Info.EngineName));
Doc.SaveLoadedDocument('scan-searchable.pdf');
end
else
Writeln('No text layer written: ', string(Info.Diagnostic));
finally
Doc.Free;
end;
end;
Conviene declarar un límite con claridad en lugar de descubrirlo después. La capa invisible utiliza una fuente Type0 sintética compartida y no incrustada, suficiente para buscar y copiar en todos los visores, pero que no satisface el requisito de incrustación de fuentes de ISO 19005. Si la salida tiene que ser PDF/A, el caller debe incrustar por separado una fuente conforme. Además, una capa de texto OCR contiene geometría, no estructura, por lo que el orden de lectura procede únicamente de las posiciones de los glifos; si necesita el orden lógico de una página que ya tiene texto real, la extracción de texto en orden estructural impulsada por el árbol de etiquetas es una herramienta distinta para un problema distinto
Dónde termina el motor integrado
El motor integrado es deliberadamente estrecho, y conocer sus bordes es lo que permite que siga siendo útil. Apunta a texto ASCII de alta calidad y alto contraste impreso a máquina, con fuentes próximas a sus cinco caras de plantilla, y todo lo que queda fuera devuelve ninguna palabra en lugar de una suposición. Los límites concretos son:
- Imágenes de hasta 4096 por 4096 y 4.194.304 píxeles, con un plazo de reconocimiento de 2000 ms y cancelación cooperativa mediante
THPDFCancellationToken - Un alfabeto de 62 caracteres de letras ASCII y dígitos; sin puntuación, caracteres acentuados ni CJK
- Solo texto alineado con los ejes, con la rotación de página que el renderer ya ha normalizado; los escaneos inclinados no se corrigen
- Las parejas de glifos ambiguas quedan sin resolver, por lo que una página puede devolver palabras parciales o el diagnóstico «found no unambiguous ASCII words»
Cuando ese envoltorio es demasiado pequeño, IHPDFOCREngine es la costura de extensión. Implemente Recognize sobre su propio engine, páselo al overload de tres argumentos de ApplyLoadedOCRTextLayer y todo lo posterior (mapeo de coordenadas, gestión de rotación, validación Unicode, presupuestos y commit atómico) permanece igual. El bitmap se presta durante la llamada síncrona y no debe conservarse. Para confirmar que la capa ha quedado bien, vuelva a cargar el archivo guardado y ejecute la ruta de texto normal descrita en la extracción de texto de un PDF cargado en Delphi; si las palabras vuelven, la capa es real
El OCR integrado por coincidencia de plantillas, la capa de texto invisible, el renderer de páginas que los alimenta y la extracción de texto del documento cargado que los verifica se distribuyen en el mismo componente VCL nativo, sin un runtime OCR externo ni una DLL que desplegar junto a la aplicación. Si está creando captura documental, archivo o búsquedas sobre PDF escaneados en Delphi o C++Builder, el componente PDF Delphi HotPDF le ofrece toda la pipeline en una sola dependencia