¿Cómo puedo extraer el subíndice / superíndice correctamente desde un PDF usando iTextSharp?

iTextSharp funciona bien extrayendo texto plano de documentos PDF, pero tengo problemas con el texto de subíndice / superíndice, común en documentos técnicos.

TextChunk.SameLine() requiere dos segmentos para tener una posición vertical idéntica para estar “en” la misma línea, que no es el caso para el texto superíndice o subíndice. Por ejemplo, en la página 11 de este documento, bajo “EFICIENCIA DE COMBUSTIÓN”:

http://www.mass.gov/courts/docs/lawlib/300-399cmr/310cmr7.pdf

Texto esperado:

 monoxide (CO) in flue gas in accordance with the following formula: CE = [CO2 /(CO + CO2)] 

Texto del resultado:

 monoxide (CO) in flue gas in accordance with the following formula: CE = [CO /(CO + CO )] 2 2 

SameLine() a LocationTextExtractionStrategy e hice getters públicos para las propiedades privadas de TextChunk que lee. Esto me permitió ajustar la tolerancia sobre la marcha en mi propia subclase, que se muestra aquí:

 public class SubSuperStrategy : LocationTextExtractionStrategy { public int SameLineOrientationTolerance { get; set; } public int SameLineDistanceTolerance { get; set; } public override bool SameLine(TextChunk chunk1, TextChunk chunk2) { var orientationDelta = Math.Abs(chunk1.OrientationMagnitude - chunk2.OrientationMagnitude); if(orientationDelta > SameLineOrientationTolerance) return false; var distDelta = Math.Abs(chunk1.DistPerpendicular - chunk2.DistPerpendicular); return (distDelta <= SameLineDistanceTolerance); } } 

Usando SameLineDistanceTolerance de 3 , esto corrige a qué línea se asignan los sub / super trozos, pero la posición relativa del texto está muy lejos:

 monoxide (CO) in flue gas in accordance with the following formula: CE = [CO /(CO + CO )] 2 2 

A veces los fragmentos se insertan en algún lugar en el medio del texto, y algunas veces (como en este ejemplo) al final. De cualquier manera, no terminan en el lugar correcto. Sospecho que esto podría tener algo que ver con el tamaño de las fonts, pero estoy en mis límites para entender las entrañas de este código.

¿Alguien ha encontrado otra manera de lidiar con esto?

(Me complace enviar una solicitud de extracción con mis cambios si eso ayuda).

Para extraer adecuadamente estos subíndices y superíndices en línea, se necesita un enfoque diferente para comprobar si dos fragmentos de texto están en la misma línea. Las siguientes clases representan uno de estos enfoques.

Estoy más en casa en Java / iText; por lo tanto, implementé este enfoque en Java primero y luego lo traduje a C # / iTextSharp.

Un enfoque que usa Java y iText

Estoy usando la twig de desarrollo actual iText 5.5.8-SNAPSHOT.

Una forma de identificar líneas

Suponiendo que las líneas de texto sean horizontales y la extensión vertical de los recuadros delimitadores de los glifos en líneas diferentes para que no se superpongan, se puede tratar de identificar líneas usando un RenderListener como este:

 public class TextLineFinder implements RenderListener { @Override public void beginTextBlock() { } @Override public void endTextBlock() { } @Override public void renderImage(ImageRenderInfo renderInfo) { } /* * @see RenderListener#renderText(TextRenderInfo) */ @Override public void renderText(TextRenderInfo renderInfo) { LineSegment ascentLine = renderInfo.getAscentLine(); LineSegment descentLine = renderInfo.getDescentLine(); float[] yCoords = new float[]{ ascentLine.getStartPoint().get(Vector.I2), ascentLine.getEndPoint().get(Vector.I2), descentLine.getStartPoint().get(Vector.I2), descentLine.getEndPoint().get(Vector.I2) }; Arrays.sort(yCoords); addVerticalUseSection(yCoords[0], yCoords[3]); } /** * This method marks the given interval as used. */ void addVerticalUseSection(float from, float to) { if (to < from) { float temp = to; to = from; from = temp; } int i=0, j=0; for (; i i) verticalFlips.remove(j); if (toOutsideInterval) verticalFlips.add(i, to); if (fromOutsideInterval) verticalFlips.add(i, from); } final List verticalFlips = new ArrayList(); } 

( TextLineFinder.java )

Este RenderListener trata de identificar las líneas de texto horizontales proyectando los cuadros delimitadores de texto en el eje y. Se supone que estas proyecciones no se superponen para el texto de diferentes líneas, incluso en el caso de los subíndices y superíndices.

Esta clase es esencialmente una forma reducida del PageVerticalAnalyzer utilizado en esta respuesta .

Ordenando trozos de texto por esas líneas

Una vez identificadas las líneas como se muestra arriba, se puede ajustar la LocationTextExtractionStrategy de iText para ordenar las líneas como esta:

 public class HorizontalTextExtractionStrategy extends LocationTextExtractionStrategy { public class HorizontalTextChunk extends TextChunk { public HorizontalTextChunk(String string, Vector startLocation, Vector endLocation, float charSpaceWidth) { super(string, startLocation, endLocation, charSpaceWidth); } @Override public int compareTo(TextChunk rhs) { if (rhs instanceof HorizontalTextChunk) { HorizontalTextChunk horRhs = (HorizontalTextChunk) rhs; int rslt = Integer.compare(getLineNumber(), horRhs.getLineNumber()); if (rslt != 0) return rslt; return Float.compare(getStartLocation().get(Vector.I1), rhs.getStartLocation().get(Vector.I1)); } else return super.compareTo(rhs); } @Override public boolean sameLine(TextChunk as) { if (as instanceof HorizontalTextChunk) { HorizontalTextChunk horAs = (HorizontalTextChunk) as; return getLineNumber() == horAs.getLineNumber(); } else return super.sameLine(as); } public int getLineNumber() { Vector startLocation = getStartLocation(); float y = startLocation.get(Vector.I2); List flips = textLineFinder.verticalFlips; if (flips == null || flips.isEmpty()) return 0; if (y < flips.get(0)) return flips.size() / 2 + 1; for (int i = 1; i < flips.size(); i+=2) { if (y < flips.get(i)) { return (1 + flips.size() - i) / 2; } } return 0; } } @Override public void renderText(TextRenderInfo renderInfo) { textLineFinder.renderText(renderInfo); LineSegment segment = renderInfo.getBaseline(); if (renderInfo.getRise() != 0){ // remove the rise from the baseline - we do this because the text from a super/subscript render operations should probably be considered as part of the baseline of the text the super/sub is relative to Matrix riseOffsetTransform = new Matrix(0, -renderInfo.getRise()); segment = segment.transformBy(riseOffsetTransform); } TextChunk location = new HorizontalTextChunk(renderInfo.getText(), segment.getStartPoint(), segment.getEndPoint(), renderInfo.getSingleSpaceWidth()); getLocationalResult().add(location); } public HorizontalTextExtractionStrategy() throws NoSuchFieldException, SecurityException { locationalResultField = LocationTextExtractionStrategy.class.getDeclaredField("locationalResult"); locationalResultField.setAccessible(true); textLineFinder = new TextLineFinder(); } @SuppressWarnings("unchecked") List getLocationalResult() { try { return (List) locationalResultField.get(this); } catch (IllegalArgumentException | IllegalAccessException e) { e.printStackTrace(); throw new RuntimeException(e); } } final Field locationalResultField; final TextLineFinder textLineFinder; } 

( HorizontalTextExtractionStrategy.java )

Esta TextExtractionStrategy utiliza un TextLineFinder para identificar líneas de texto horizontales y luego utiliza esta información para ordenar los fragmentos de texto.

Tenga cuidado, este código usa la reflexión para acceder a los miembros privados de la clase padre. Esto podría no estar permitido en todos los entornos. En tal caso, simplemente copie el LocationTextExtractionStrategy e inserte el código directamente.

Extrayendo el texto

Ahora uno puede usar esta estrategia de extracción de texto para extraer el texto con superíndices y subíndices en línea como este:

 String extract(PdfReader reader, int pageNo) throws IOException, NoSuchFieldException, SecurityException { return PdfTextExtractor.getTextFromPage(reader, pageNo, new HorizontalTextExtractionStrategy()); } 

(de ExtractSuperAndSubInLine.java )

El texto de ejemplo en la página 11 del documento del PO, en “EFICIENCIA DE COMBUSTIÓN”, ahora se extrae así:

 monoxide (CO) in flue gas in accordance with the following formula: CE = [CO 2/(CO + CO 2 )] 

El mismo enfoque usando C # & iTextSharp

Las explicaciones, advertencias y resultados de muestra de la sección centrada en Java aún se aplican, aquí está el código:

Estoy usando iTextSharp 5.5.7.

Una forma de identificar líneas

 public class TextLineFinder : IRenderListener { public void BeginTextBlock() { } public void EndTextBlock() { } public void RenderImage(ImageRenderInfo renderInfo) { } public void RenderText(TextRenderInfo renderInfo) { LineSegment ascentLine = renderInfo.GetAscentLine(); LineSegment descentLine = renderInfo.GetDescentLine(); float[] yCoords = new float[]{ ascentLine.GetStartPoint()[Vector.I2], ascentLine.GetEndPoint()[Vector.I2], descentLine.GetStartPoint()[Vector.I2], descentLine.GetEndPoint()[Vector.I2] }; Array.Sort(yCoords); addVerticalUseSection(yCoords[0], yCoords[3]); } void addVerticalUseSection(float from, float to) { if (to < from) { float temp = to; to = from; from = temp; } int i=0, j=0; for (; i i) verticalFlips.RemoveAt(j); if (toOutsideInterval) verticalFlips.Insert(i, to); if (fromOutsideInterval) verticalFlips.Insert(i, from); } public List verticalFlips = new List(); } 

Ordenando trozos de texto por esas líneas

 public class HorizontalTextExtractionStrategy : LocationTextExtractionStrategy { public class HorizontalTextChunk : TextChunk { public HorizontalTextChunk(String stringValue, Vector startLocation, Vector endLocation, float charSpaceWidth, TextLineFinder textLineFinder) : base(stringValue, startLocation, endLocation, charSpaceWidth) { this.textLineFinder = textLineFinder; } override public int CompareTo(TextChunk rhs) { if (rhs is HorizontalTextChunk) { HorizontalTextChunk horRhs = (HorizontalTextChunk) rhs; int rslt = CompareInts(getLineNumber(), horRhs.getLineNumber()); if (rslt != 0) return rslt; return CompareFloats(StartLocation[Vector.I1], rhs.StartLocation[Vector.I1]); } else return base.CompareTo(rhs); } public override bool SameLine(TextChunk a) { if (a is HorizontalTextChunk) { HorizontalTextChunk horAs = (HorizontalTextChunk) a; return getLineNumber() == horAs.getLineNumber(); } else return base.SameLine(a); } public int getLineNumber() { Vector startLocation = StartLocation; float y = startLocation[Vector.I2]; List flips = textLineFinder.verticalFlips; if (flips == null || flips.Count == 0) return 0; if (y < flips[0]) return flips.Count / 2 + 1; for (int i = 1; i < flips.Count; i+=2) { if (y < flips[i]) { return (1 + flips.Count - i) / 2; } } return 0; } private static int CompareInts(int int1, int int2){ return int1 == int2 ? 0 : int1 < int2 ? -1 : 1; } private static int CompareFloats(float float1, float float2) { return float1 == float2 ? 0 : float1 < float2 ? -1 : 1; } TextLineFinder textLineFinder; } public override void RenderText(TextRenderInfo renderInfo) { textLineFinder.RenderText(renderInfo); LineSegment segment = renderInfo.GetBaseline(); if (renderInfo.GetRise() != 0){ // remove the rise from the baseline - we do this because the text from a super/subscript render operations should probably be considered as part of the baseline of the text the super/sub is relative to Matrix riseOffsetTransform = new Matrix(0, -renderInfo.GetRise()); segment = segment.TransformBy(riseOffsetTransform); } TextChunk location = new HorizontalTextChunk(renderInfo.GetText(), segment.GetStartPoint(), segment.GetEndPoint(), renderInfo.GetSingleSpaceWidth(), textLineFinder); getLocationalResult().Add(location); } public HorizontalTextExtractionStrategy() { locationalResultField = typeof(LocationTextExtractionStrategy).GetField("locationalResult", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); textLineFinder = new TextLineFinder(); } List getLocationalResult() { return (List) locationalResultField.GetValue(this); } System.Reflection.FieldInfo locationalResultField; TextLineFinder textLineFinder; } 

Extrayendo el texto

  string extract(PdfReader reader, int pageNo) { return PdfTextExtractor.GetTextFromPage(reader, pageNo, new HorizontalTextExtractionStrategy()); } 

ACTUALIZACIÓN: Cambios en LocationTextExtractionStrategy

En iText 5.5.9-SNAPSHOT Commite 53526e4854fcb80c86cbc2e113f7a07401dc9a67 (“Refactor LocationTextExtractionStrategy …”) a través de 1ab350beae148be2a4bef5e663b3d67a004ff9f8 (“Hacer que TextChunkLocation sea una <> clase comparable …”) la arquitectura LocationTextExtractionStrategy se ha modificado para permitir personalizaciones como esta sin la necesidad de reflexión.

Lamentablemente, este cambio rompe la HorizontalTextExtractionStrategy presentada anteriormente. Para las versiones de iText después de esos commits uno puede usar la siguiente estrategia:

 public class HorizontalTextExtractionStrategy2 extends LocationTextExtractionStrategy { public static class HorizontalTextChunkLocationStrategy implements TextChunkLocationStrategy { public HorizontalTextChunkLocationStrategy(TextLineFinder textLineFinder) { this.textLineFinder = textLineFinder; } @Override public TextChunkLocation createLocation(TextRenderInfo renderInfo, LineSegment baseline) { return new HorizontalTextChunkLocation(baseline.getStartPoint(), baseline.getEndPoint(), renderInfo.getSingleSpaceWidth()); } final TextLineFinder textLineFinder; public class HorizontalTextChunkLocation implements TextChunkLocation { /** the starting location of the chunk */ private final Vector startLocation; /** the ending location of the chunk */ private final Vector endLocation; /** unit vector in the orientation of the chunk */ private final Vector orientationVector; /** the orientation as a scalar for quick sorting */ private final int orientationMagnitude; /** perpendicular distance to the orientation unit vector (ie the Y position in an unrotated coordinate system) * we round to the nearest integer to handle the fuzziness of comparing floats */ private final int distPerpendicular; /** distance of the start of the chunk parallel to the orientation unit vector (ie the X position in an unrotated coordinate system) */ private final float distParallelStart; /** distance of the end of the chunk parallel to the orientation unit vector (ie the X position in an unrotated coordinate system) */ private final float distParallelEnd; /** the width of a single space character in the font of the chunk */ private final float charSpaceWidth; public HorizontalTextChunkLocation(Vector startLocation, Vector endLocation, float charSpaceWidth) { this.startLocation = startLocation; this.endLocation = endLocation; this.charSpaceWidth = charSpaceWidth; Vector oVector = endLocation.subtract(startLocation); if (oVector.length() == 0) { oVector = new Vector(1, 0, 0); } orientationVector = oVector.normalize(); orientationMagnitude = (int)(Math.atan2(orientationVector.get(Vector.I2), orientationVector.get(Vector.I1))*1000); // see http://mathworld.wolfram.com/Point-LineDistance2-Dimensional.html // the two vectors we are crossing are in the same plane, so the result will be purely // in the z-axis (out of plane) direction, so we just take the I3 component of the result Vector origin = new Vector(0,0,1); distPerpendicular = (int)(startLocation.subtract(origin)).cross(orientationVector).get(Vector.I3); distParallelStart = orientationVector.dot(startLocation); distParallelEnd = orientationVector.dot(endLocation); } public int orientationMagnitude() { return orientationMagnitude; } public int distPerpendicular() { return distPerpendicular; } public float distParallelStart() { return distParallelStart; } public float distParallelEnd() { return distParallelEnd; } public Vector getStartLocation() { return startLocation; } public Vector getEndLocation() { return endLocation; } public float getCharSpaceWidth() { return charSpaceWidth; } /** * @param as the location to compare to * @return true is this location is on the the same line as the other */ public boolean sameLine(TextChunkLocation as) { if (as instanceof HorizontalTextChunkLocation) { HorizontalTextChunkLocation horAs = (HorizontalTextChunkLocation) as; return getLineNumber() == horAs.getLineNumber(); } else return orientationMagnitude() == as.orientationMagnitude() && distPerpendicular() == as.distPerpendicular(); } /** * Computes the distance between the end of 'other' and the beginning of this chunk * in the direction of this chunk's orientation vector. Note that it's a bad idea * to call this for chunks that aren't on the same line and orientation, but we don't * explicitly check for that condition for performance reasons. * @param other * @return the number of spaces between the end of 'other' and the beginning of this chunk */ public float distanceFromEndOf(TextChunkLocation other) { float distance = distParallelStart() - other.distParallelEnd(); return distance; } public boolean isAtWordBoundary(TextChunkLocation previous) { /** * Here we handle a very specific case which in PDF may look like: * -.232 Tc [( P)-226.2(r)-231.8(e)-230.8(f)-238(a)-238.9(c)-228.9(e)]TJ * The font's charSpace width is 0.232 and it's compensated with charSpacing of 0.232. * And a resultant TextChunk.charSpaceWidth comes to TextChunk constructor as 0. * In this case every chunk is considered as a word boundary and space is added. * We should consider charSpaceWidth equal (or close) to zero as a no-space. */ if (getCharSpaceWidth() < 0.1f) return false; float dist = distanceFromEndOf(previous); return dist < -getCharSpaceWidth() || dist > getCharSpaceWidth()/2.0f; } public int getLineNumber() { Vector startLocation = getStartLocation(); float y = startLocation.get(Vector.I2); List flips = textLineFinder.verticalFlips; if (flips == null || flips.isEmpty()) return 0; if (y < flips.get(0)) return flips.size() / 2 + 1; for (int i = 1; i < flips.size(); i+=2) { if (y < flips.get(i)) { return (1 + flips.size() - i) / 2; } } return 0; } @Override public int compareTo(TextChunkLocation rhs) { if (rhs instanceof HorizontalTextChunkLocation) { HorizontalTextChunkLocation horRhs = (HorizontalTextChunkLocation) rhs; int rslt = Integer.compare(getLineNumber(), horRhs.getLineNumber()); if (rslt != 0) return rslt; return Float.compare(getStartLocation().get(Vector.I1), rhs.getStartLocation().get(Vector.I1)); } else { int rslt; rslt = Integer.compare(orientationMagnitude(), rhs.orientationMagnitude()); if (rslt != 0) return rslt; rslt = Integer.compare(distPerpendicular(), rhs.distPerpendicular()); if (rslt != 0) return rslt; return Float.compare(distParallelStart(), rhs.distParallelStart()); } } } } @Override public void renderText(TextRenderInfo renderInfo) { textLineFinder.renderText(renderInfo); super.renderText(renderInfo); } public HorizontalTextExtractionStrategy2() throws NoSuchFieldException, SecurityException { this(new TextLineFinder()); } public HorizontalTextExtractionStrategy2(TextLineFinder textLineFinder) throws NoSuchFieldException, SecurityException { super(new HorizontalTextChunkLocationStrategy(textLineFinder)); this.textLineFinder = textLineFinder; } final TextLineFinder textLineFinder; } 

( HorizontalTextExtractionStrategy2.java )

Acabo de resolver un problema similar, ver mi pregunta . Detecto subíndices como texto que tiene una línea de base entre las líneas Ascendente y Descendente del texto anterior. Este código cortado puede ser útil:

  Vector thisFacade = this.ascentLine.GetStartPoint().Subtract(this.descentLine.GetStartPoint()); Vector infoFacade = renderInfo.GetAscentLine().GetStartPoint().Subtract(renderInfo.GetDescentLine().GetStartPoint()); if (baseVector.Cross(ascent2base).Dot(baseVector.Cross(descent2base)) < 0 && infoFacade.LengthSquared < thisFacade.LengthSquared - sameHeightThreshols) 

Más detalles después de Chistmass.