RED-6368: Include Tables and Images into Prototype Document Structure

*added Tables And TableCell
*added BoundingBox calculation function
*added images to structure without restructuring them, some images are still added twice
This commit is contained in:
Kilian Schuettler 2023-03-17 15:15:35 +01:00 committed by Kilian Schuettler
parent f8fe88dc3c
commit 7a529d5a88
56 changed files with 3336 additions and 1345 deletions

View File

@ -1,23 +1,16 @@
package com.iqser.red.service.redaction.v1.server.classification.model;
import com.iqser.red.service.persistence.service.v1.api.model.annotations.AnnotationStatus;
import com.iqser.red.service.persistence.service.v1.api.model.annotations.entitymapped.IdRemoval;
import com.iqser.red.service.persistence.service.v1.api.model.annotations.entitymapped.ManualImageRecategorization;
import com.iqser.red.service.redaction.v1.server.redaction.model.Entities;
import com.iqser.red.service.redaction.v1.server.redaction.model.Image;
import java.util.ArrayList;
import java.util.List;
import com.iqser.red.service.redaction.v1.server.redaction.model.PdfImage;
import com.iqser.red.service.redaction.v1.server.redaction.model.SearchableText;
import com.iqser.red.service.redaction.v1.server.redaction.utils.IdBuilder;
import com.iqser.red.service.redaction.v1.server.tableextraction.model.AbstractTextContainer;
import com.iqser.red.service.redaction.v1.server.tableextraction.model.Table;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@Data
@NoArgsConstructor
public class Section implements Comparable {
@ -68,10 +61,4 @@ public class Section implements Comparable {
return 0;
}
}

View File

@ -20,7 +20,6 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
import com.iqser.red.service.persistence.service.v1.api.shared.model.redactionlog.section.SectionArea;
import com.iqser.red.service.redaction.v1.server.redaction.model.CellValue;
import com.iqser.red.service.redaction.v1.server.redaction.model.Image;
import com.iqser.red.service.redaction.v1.server.redaction.model.Paragraph;
import com.iqser.red.service.redaction.v1.server.redaction.model.SearchableText;
import lombok.AllArgsConstructor;
@ -39,7 +38,6 @@ public class SectionText {
private boolean isTable;
private String headline;
List<Paragraph> paragraphs;
@Builder.Default
private List<SectionArea> sectionAreas = new ArrayList<>();

View File

@ -14,7 +14,4 @@ public class PageData {
int number;
int height;
int width;
Long header;
Long footer;
}

View File

@ -24,14 +24,14 @@ public class TableOfContentsData {
List<EntryData> entries;
public EntryData get(String tocId) {
public EntryData get(List<Integer> tocId) {
List<Integer> ids = getIds(tocId);
if (ids.size() < 1) {
if (tocId.size() < 1) {
throw new InvalidKeyException(format("Section Identifier: \"%s\" is not valid.", tocId));
}
EntryData entry = entries.get(ids.get(0));
for (int id : ids.subList(1, ids.size())) {
EntryData entry = entries.get(tocId.get(0));
for (int id : tocId.subList(1, tocId.size())) {
entry = entry.subEntries().get(id);
}
return entry;
@ -45,7 +45,7 @@ public class TableOfContentsData {
@Builder
public record EntryData(String tocId, List<EntryData> subEntries, NodeType type, Long atomicTextBlock, Long page, int numberOnPage) {
public record EntryData(int[] tocId, List<EntryData> subEntries, NodeType type, Long[] atomicTextBlocks, Long[] pages, int numberOnPage) {
}

View File

@ -5,7 +5,7 @@ import static java.lang.String.format;
import lombok.Setter;
@Setter
public class Boundary {
public class Boundary implements Comparable<Boundary> {
private int start;
private int end;
@ -13,7 +13,9 @@ public class Boundary {
public Boundary(int start, int end) {
assert start <= end;
if (start > end) {
throw new IllegalArgumentException(format("start: %d > end: %d", start, end));
}
this.start = start;
this.end = end;
}
@ -75,7 +77,7 @@ public class Boundary {
public boolean intersects(Boundary boundary) {
return contains(boundary.start()) || contains(boundary.end());
return contains(boundary.start()) || contains(boundary.end() - 1);
}
@ -85,4 +87,17 @@ public class Boundary {
return format("Boundary [%d|%d)", start, end);
}
@Override
public int compareTo(Boundary boundary) {
if (end < boundary.end() && start < boundary.start()) {
return -1;
}
if (start > boundary.start() && end > boundary.end()) {
return 1;
}
return 0;
}
}

View File

@ -1,22 +1,16 @@
package com.iqser.red.service.redaction.v1.server.document.graph;
import static com.iqser.red.service.redaction.v1.server.document.services.EntityEnrichmentUtility.enrichEntity;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.DocumentGraphNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.EntityNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.PageNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.SemanticNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.EntityNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.SectionNode;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.AtomicTextBlock;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.ConcatenatedTextBlock;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlock;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlockCollector;
import com.iqser.red.service.redaction.v1.server.exception.NotFoundException;
import com.iqser.red.service.redaction.v1.server.redaction.model.EntityType;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
@ -32,56 +26,37 @@ import lombok.extern.slf4j.Slf4j;
@FieldDefaults(level = AccessLevel.PRIVATE)
public class DocumentGraph {
List<SectionNode> sections;
List<PageNode> pages;
TableOfContents tableOfContents;
Integer numberOfPages;
TextBlock text;
TextBlock textBlock;
public ConcatenatedTextBlock buildTextBlock() {
public TextBlock buildTextBlock() {
return streamAtomicTextBlocksInOrder().collect(new TextBlockCollector());
return streamTextBlocksInOrder().collect(new TextBlockCollector());
}
public Stream<AtomicTextBlock> streamAtomicTextBlocksInOrder() {
public List<SectionNode> getMainSections() {
return Stream.concat(//
streamAllNodes().filter(DocumentGraphNode::isTerminal).map(DocumentGraphNode::getAtomicTextBlock),//
Stream.concat(//
pages.stream().map(PageNode::getHeader),//
pages.stream().map(PageNode::getFooter)));
return tableOfContents.entries.stream().filter(entry -> entry.node() instanceof SectionNode).map(entry -> (SectionNode) entry.node()).collect(Collectors.toList());
}
public EntityNode createAndAddEntity(Boundary boundary, String type, EntityType entityType) {
public Stream<TextBlock> streamTextBlocksInOrder() {
EntityNode entity = EntityNode.initialEntityNode(boundary, type, entityType);
addEntityToGraphAndSetFields(entity);
return entity;
}
public void addEntityToGraphAndSetFields(EntityNode entity) {
try {
boolean inserted = streamAllNodes().anyMatch(node -> node.addEntityAndSetFieldsIfStartIndexContained(entity));
} catch (NotFoundException e) {
enrichEntity(entity, text);
log.warn("Entity \"{}\" with {} is in between two main sections and will be removed!", entity.getValue(), entity.getBoundary());
entity.removeFromGraph();
}
return streamAllNodes().filter(SemanticNode::isTerminal).map(SemanticNode::getTerminalTextBlock);
}
public Set<EntityNode> getEntities() {
return streamAllNodes().filter(DocumentGraphNode::isTerminal).map(DocumentGraphNode::getEntities).flatMap(List::stream).collect(Collectors.toSet());
return streamAllNodes().map(SemanticNode::getEntities).flatMap(Set::stream).collect(Collectors.toUnmodifiableSet());
}
private Stream<DocumentGraphNode> streamAllNodes() {
private Stream<SemanticNode> streamAllNodes() {
return tableOfContents.streamEntriesInOrder().map(TableOfContents.Entry::node);
}
@ -90,7 +65,7 @@ public class DocumentGraph {
@Override
public String toString() {
return text.toString();
return tableOfContents.toString();
}
}

View File

@ -0,0 +1,14 @@
package com.iqser.red.service.redaction.v1.server.document.graph;
import java.util.Map;
import com.iqser.red.service.redaction.v1.server.redaction.model.RedRectangle2D;
import lombok.Data;
@Data
public class PageElement {
Map<Integer, RedRectangle2D> positionsPerPage;
}

View File

@ -3,17 +3,18 @@ package com.iqser.red.service.redaction.v1.server.document.graph;
import static java.lang.String.format;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.stream.Stream;
import javax.management.openmbean.InvalidKeyException;
import com.google.common.hash.Hashing;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.DocumentGraphNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.SemanticNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.NodeType;
import lombok.Builder;
import lombok.Data;
@Data
@ -28,37 +29,96 @@ public class TableOfContents {
}
public String createNewEntryAndReturnId(NodeType nodeType, String summary, DocumentGraphNode node) {
public List<Integer> createNewEntryAndReturnId(NodeType nodeType, SemanticNode node) {
String id = String.format("%d", entries.size());
entries.add(new Entry(nodeType, id, summary, new LinkedList<>(), node));
return id;
return createNewChildEntryAndReturnId(Collections.emptyList(), nodeType, node);
}
public String createNewChildEntryAndReturnId(String parentId, NodeType nodeType, String summary, DocumentGraphNode node) {
public List<Integer> createNewChildEntryAndReturnId(List<Integer> parentId, NodeType nodeType, SemanticNode node) {
Entry parent = getEntryById(parentId);
String childId = parentId + String.format(".%d", parent.children().size());
parent.children().add(new Entry(nodeType, childId, summary, new LinkedList<>(), node));
return childId;
}
public Entry getEntryById(String parentId) {
List<Integer> ids = getIds(parentId);
if (ids.size() < 1) {
throw new InvalidKeyException(format("Section Identifier: \"%s\" is not valid.", parentId));
List<Integer> newId;
if (entryExists(parentId)) {
Entry parent = getEntryById(parentId);
newId = new LinkedList<>(parentId);
newId.add(parent.children().size());
parent.children().add(Entry.builder().tocId(newId).node(node).type(nodeType).children(new LinkedList<>()).build());
} else {
newId = List.of(entries.size());
entries.add(Entry.builder().tocId(newId).node(node).type(nodeType).children(new LinkedList<>()).build());
}
Entry entry = entries.get(ids.get(0));
for (int id : ids.subList(1, ids.size())) {
return newId;
}
private boolean entryExists(List<Integer> tocId) {
if (tocId.size() < 1) {
return false;
}
Entry entry = entries.get(tocId.get(0));
for (int id : tocId.subList(1, tocId.size())) {
if (id >= entry.children.size() || 0 > id) {
return false;
}
entry = entry.children().get(id);
}
return true;
}
public Entry getParentEntryById(List<Integer> tocId) {
List<Integer> parentIds = getParentId(tocId);
if (parentIds.size() < 1) {
throw new UnsupportedOperationException(format("Node with tocId \"%s\" has no parent!", tocId));
}
return getEntryById(parentIds);
}
public boolean hasParentById(List<Integer> tocId) {
List<Integer> parentId = getParentId(tocId);
return entryExists(parentId);
}
public Stream<SemanticNode> streamChildren(List<Integer> tocId) {
return getEntryById(tocId).children().stream().map(Entry::node);
}
private static List<Integer> getParentId(List<Integer> tocId) {
return tocId.subList(0, tocId.size() - 1);
}
public Entry getEntryById(List<Integer> tocId) {
Entry entry = entries.get(tocId.get(0));
for (int id : tocId.subList(1, tocId.size())) {
entry = entry.children().get(id);
}
return entry;
}
public Stream<Entry> streamEntriesInOrder() {
return entries.stream().flatMap(TableOfContents::flatten);
}
public Stream<Entry> streamSubEntriesInOrder(List<Integer> parentId) {
return Stream.of(getEntryById(parentId)).flatMap(TableOfContents::flatten);
}
@Override
public String toString() {
@ -66,48 +126,44 @@ public class TableOfContents {
}
public String toString(String id) {
public String toString(List<Integer> id) {
return String.join("\n", streamSubEntriesInOrder(id).map(Entry::toString).toList());
}
public Stream<Entry> streamEntriesInOrder() {
return entries.stream().flatMap(TableOfContents::flatten);
}
public Stream<Entry> streamSubEntriesInOrder(String parentId) {
return Stream.of(getEntryById(parentId)).flatMap(TableOfContents::flatten);
}
private static List<Integer> getIds(String idsAsString) {
return Arrays.stream(idsAsString.split("\\.")).map(Integer::valueOf).toList();
}
private static Stream<Entry> flatten(Entry entry) {
return Stream.concat(Stream.of(entry), entry.children().stream().flatMap(TableOfContents::flatten));
}
public record Entry(NodeType type, String id, String summary, List<Entry> children, DocumentGraphNode node) {
@Builder
public record Entry(List<Integer> tocId, NodeType type, SemanticNode node, List<Entry> children) {
@Override
public String toString() {
return id + ": " + type + ".: " + summary;
return tocId + ": " + type + ".: " + buildSummary(node().buildTextBlock().getSearchText());
}
private static String buildSummary(CharSequence text) {
String[] words = text.toString().split(" ");
int bound = Math.min(words.length, 4);
List<String> list = new ArrayList<>(Arrays.asList(words).subList(0, bound));
return String.join(" ", list);
}
@Override
public int hashCode() {
return Hashing.murmur3_32_fixed().hashString(type + id + summary + children.hashCode(), StandardCharsets.UTF_8).hashCode();
return Hashing.murmur3_32_fixed().hashString(toString(), StandardCharsets.UTF_8).hashCode();
}
}
}

View File

@ -0,0 +1,392 @@
package com.iqser.red.service.redaction.v1.server.document.graph.factory;
import static com.iqser.red.service.redaction.v1.server.document.graph.factory.RectangleTransformations.toRectangle2D;
import static com.iqser.red.service.redaction.v1.server.document.graph.factory.TextPositionOperations.mergeAndSortTextPositionSequenceByYThenX;
import static com.iqser.red.service.redaction.v1.server.document.graph.nodes.NodeType.FOOTER;
import static com.iqser.red.service.redaction.v1.server.document.graph.nodes.NodeType.HEADER;
import static com.iqser.red.service.redaction.v1.server.document.graph.nodes.NodeType.TABLE_CELL;
import static java.lang.String.format;
import static java.util.stream.Collectors.groupingBy;
import static java.util.stream.Collectors.toList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import com.iqser.red.service.redaction.v1.server.classification.model.Document;
import com.iqser.red.service.redaction.v1.server.classification.model.Footer;
import com.iqser.red.service.redaction.v1.server.classification.model.Header;
import com.iqser.red.service.redaction.v1.server.classification.model.Page;
import com.iqser.red.service.redaction.v1.server.classification.model.TextBlock;
import com.iqser.red.service.redaction.v1.server.document.graph.DocumentGraph;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.PageNode;
import com.iqser.red.service.redaction.v1.server.document.graph.TableOfContents;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.SemanticNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.FooterNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.HeaderNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.HeadlineNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.ImageNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.NodeType;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.ParagraphNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.SectionNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.TableCellNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.TableNode;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.AtomicTextBlock;
import com.iqser.red.service.redaction.v1.server.document.services.ImageSortService;
import com.iqser.red.service.redaction.v1.server.exception.NotFoundException;
import com.iqser.red.service.redaction.v1.server.parsing.model.TextPositionSequence;
import com.iqser.red.service.redaction.v1.server.redaction.model.PdfImage;
import com.iqser.red.service.redaction.v1.server.tableextraction.model.AbstractTextContainer;
import com.iqser.red.service.redaction.v1.server.tableextraction.model.Cell;
import com.iqser.red.service.redaction.v1.server.tableextraction.model.Table;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class DocumentGraphFactory {
public static final double TABLE_CELL_MERGE_SIZE_THRESHOLD = 0.05;
private final ImageSortService imageSortService;
public DocumentGraph buildDocumentGraph(Document document) {
ImageSortService.SortedImages sortedImages = imageSortService.sortImagesIntoStructure(document);
TextBlockFactory textBlockFactory = new TextBlockFactory();
Context context = new Context(new TableOfContents(), new HashMap<>(), new LinkedList<>(), new LinkedList<>(), textBlockFactory);
document.getPages().stream().map(this::buildPage).forEach(page -> context.pages().put(page, new AtomicInteger(1)));
document.getSections().stream().flatMap(section -> section.getImages().stream()).forEach(image -> context.images().add(image));
addSections(document, context);
addHeaderAndFooterToEachPage(document, context);
DocumentGraph documentGraph = DocumentGraph.builder()
.numberOfPages(context.pages.size())
.pages(context.pages.keySet().stream().sorted(Comparator.comparingInt(PageNode::getNumber)).toList())
.tableOfContents(context.tableOfContents)
.build();
documentGraph.setTextBlock(documentGraph.buildTextBlock());
return documentGraph;
}
private void addSections(Document document, Context context) {
document.getSections().forEach(section -> addSection(null, section.getPageBlocks(), section.getImages(), context));
}
private void addSection(SemanticNode parentNode, List<AbstractTextContainer> pageBlocks, List<PdfImage> images, Context context) {
Map<Integer, List<AbstractTextContainer>> blocksPerPage = pageBlocks.stream().collect(groupingBy(AbstractTextContainer::getPage));
SectionNode sectionNode = SectionNode.builder().entities(new HashSet<>()).pages(new HashSet<>()).tableOfContents(context.tableOfContents()).build();
context.sections().add(sectionNode);
blocksPerPage.keySet().forEach(pageNumber -> addSectionNodeToPageNode(context, sectionNode, pageNumber));
List<Integer> tocId;
if (parentNode == null) {
tocId = context.tableOfContents.createNewEntryAndReturnId(NodeType.SECTION, sectionNode);
} else {
tocId = context.tableOfContents.createNewChildEntryAndReturnId(parentNode.getTocId(), NodeType.SECTION, sectionNode);
}
sectionNode.setTocId(tocId);
Set<AbstractTextContainer> alreadyMerged = new HashSet<>();
for (AbstractTextContainer abstractTextContainer : pageBlocks) {
if (alreadyMerged.contains(abstractTextContainer)) {
continue;
}
if (abstractTextContainer instanceof TextBlock) {
List<TextBlock> textBlocks = findTextBlocksWithSameClassificationAndAlignsY(abstractTextContainer, pageBlocks);
alreadyMerged.addAll(textBlocks);
addParagraphOrHeadline(sectionNode, (TextBlock) abstractTextContainer, context, textBlocks);
}
if (abstractTextContainer instanceof Table) {
addTable(sectionNode, (Table) abstractTextContainer, context);
}
}
for (PdfImage image : images) {
addImage(sectionNode, image, context);
}
}
private List<TextBlock> findTextBlocksWithSameClassificationAndAlignsY(AbstractTextContainer atc, List<AbstractTextContainer> pageBlocks) {
return pageBlocks.stream()
.filter(abstractTextContainer -> abstractTextContainer instanceof TextBlock)
.filter(abstractTextContainer -> abstractTextContainer.intersectsY(atc))
.filter(abstractTextContainer -> abstractTextContainer.getPage() == atc.getPage())
.map(abstractTextContainer -> (TextBlock) abstractTextContainer)
.toList();
}
private void addSectionNodeToPageNode(Context context, SectionNode sectionNode, Integer pageNumber) {
PageNode page = getPage(pageNumber, context);
sectionNode.getPages().add(page);
page.getMainBody().add(sectionNode);
}
private void addTable(SemanticNode parentNode, Table table, Context context) {
PageNode page = getPage(table.getPage(), context);
TableNode tableNode = TableNode.builder()
.tableOfContents(context.tableOfContents())
.pages(Collections.singleton(page))
.numberOfCols(table.getColCount())
.numberOfRows(table.getRowCount())
.build();
if (!page.getMainBody().contains(parentNode)) {
parentNode.getPages().add(page);
}
page.getMainBody().add(tableNode);
List<Integer> tocId = context.tableOfContents().createNewChildEntryAndReturnId(parentNode.getTocId(), NodeType.TABLE, tableNode);
tableNode.setTocId(tocId);
addTableCells(table.getRows(), tableNode, context);
}
private void addTableCells(List<List<Cell>> rows, SemanticNode parentNode, Context context) {
for (int rowIndex = 0; rowIndex < rows.size(); rowIndex++) {
for (int colIndex = 0; colIndex < rows.get(rowIndex).size(); colIndex++) {
addTableCell(rows.get(rowIndex).get(colIndex), rowIndex, colIndex, parentNode, context);
}
}
}
private void addTableCell(Cell cell, int rowIndex, int colIndex, SemanticNode parentNode, Context context) {
PageNode page = parentNode.getFirstPage();
TableCellNode tableCellNode = TableCellNode.builder()
.tableOfContents(context.tableOfContents())
.row(rowIndex)
.col(colIndex)
.header(cell.isHeaderCell())
.pages(new HashSet<>())
.bBox(cell.getBounds2D())
.build();
tableCellNode.getPages().add(page);
page.getMainBody().add(tableCellNode);
com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlock textBlock;
List<Integer> tocId = context.tableOfContents().createNewChildEntryAndReturnId(parentNode.getTocId(), TABLE_CELL, tableCellNode);
tableCellNode.setTocId(tocId);
if (cell.getTextBlocks().isEmpty()) {
tableCellNode.setTerminal(false);
return;
}
if (cell.getTextBlocks().size() == 1) {
textBlock = context.textBlockFactory().buildAtomicTextBlock(cell.getTextBlocks().get(0).getSequences(), tableCellNode, context, page);
tableCellNode.setTerminalTextBlock(textBlock);
} else if (StringUtils.startsWith(cell.getTextBlocks().get(0).getClassification(), "H")) {
tableCellNode.setTerminal(false);
addSection(tableCellNode, cell.getTextBlocks().stream().map(tb -> (AbstractTextContainer) tb).toList(), Collections.emptyList(), context);
} else if (cell.getArea() < TABLE_CELL_MERGE_SIZE_THRESHOLD * page.getHeight() * page.getWidth()) {
List<TextPositionSequence> sequences = mergeAndSortTextPositionSequenceByYThenX(cell.getTextBlocks());
textBlock = context.textBlockFactory().buildAtomicTextBlock(sequences, tableCellNode, context, page);
tableCellNode.setTerminalTextBlock(textBlock);
} else {
tableCellNode.setTerminal(false);
cell.getTextBlocks().forEach(tb -> addParagraphOrHeadline(tableCellNode, tb, context));
}
}
private void addParagraphOrHeadline(SemanticNode parentNode, TextBlock originalTextBlock, Context context) {
addParagraphOrHeadline(parentNode, originalTextBlock, context, Collections.emptyList());
}
private void addParagraphOrHeadline(SemanticNode parentNode, TextBlock originalTextBlock, Context context, List<TextBlock> textBlocksToMerge) {
PageNode page;
if (originalTextBlock.getPage() == 0) {
page = parentNode.getFirstPage();
} else {
page = getPage(originalTextBlock.getPage(), context);
}
SemanticNode node;
if (StringUtils.startsWith(originalTextBlock.getClassification(), "H")) {
node = HeadlineNode.builder().pages(Collections.singleton(page)).tableOfContents(context.tableOfContents()).build();
} else {
node = ParagraphNode.builder().pages(Collections.singleton(page)).tableOfContents(context.tableOfContents()).build();
}
if (!page.getMainBody().contains(parentNode)) {
parentNode.getPages().add(page);
}
page.getMainBody().add(node);
List<TextBlock> textBlocks = new LinkedList<>(textBlocksToMerge);
textBlocks.add(originalTextBlock);
AtomicTextBlock textBlock = context.textBlockFactory.buildAtomicTextBlock(mergeAndSortTextPositionSequenceByYThenX(textBlocks), node, context, page);
if (node instanceof HeadlineNode headlineNode) {
List<Integer> tocId = context.tableOfContents.createNewChildEntryAndReturnId(parentNode.getTocId(), NodeType.HEADLINE, node);
headlineNode.setTerminalTextBlock(textBlock);
headlineNode.setTocId(tocId);
}
if (node instanceof ParagraphNode paragraphNode) {
List<Integer> tocId = context.tableOfContents.createNewChildEntryAndReturnId(parentNode.getTocId(), NodeType.PARAGRAPH, node);
paragraphNode.setTerminalTextBlock(textBlock);
paragraphNode.setTocId(tocId);
}
}
private void addImage(SectionNode sectionNode, PdfImage image, Context context) {
PageNode page = getPage(image.getPage(), context);
ImageNode imageNode = ImageNode.builder()
.imageType(image.getImageType())
.position(toRectangle2D(image.getPosition()))
.transparency(image.isHasTransparency())
.page(page)
.tableOfContents(context.tableOfContents())
.build();
page.getMainBody().add(imageNode);
List<Integer> tocId = context.tableOfContents().createNewChildEntryAndReturnId(sectionNode.getTocId(), NodeType.IMAGE, imageNode);
imageNode.setTocId(tocId);
}
private void addHeaderAndFooterToEachPage(Document document, Context context) {
Map<Integer, List<TextBlock>> headers = document.getHeaders()
.stream()
.map(Header::getTextBlocks)
.flatMap(List::stream)
.collect(groupingBy(AbstractTextContainer::getPage, toList()));
Map<Integer, List<TextBlock>> footers = document.getFooters()
.stream()
.map(Footer::getTextBlocks)
.flatMap(List::stream)
.collect(groupingBy(AbstractTextContainer::getPage, toList()));
for (int pageIndex = 1; pageIndex <= document.getPages().size(); pageIndex++) {
if (headers.containsKey(pageIndex)) {
addHeader(headers.get(pageIndex), context);
} else {
addEmptyHeader(pageIndex, context);
}
}
for (int pageIndex = 1; pageIndex <= document.getPages().size(); pageIndex++) {
if (footers.containsKey(pageIndex)) {
addFooter(footers.get(pageIndex), context);
} else {
addEmptyFooter(pageIndex, context);
}
}
}
private void addFooter(List<TextBlock> textBlocks, Context context) {
PageNode page = getPage(textBlocks.get(0).getPage(), context);
FooterNode footer = FooterNode.builder().page(page).tableOfContents(context.tableOfContents()).build();
AtomicTextBlock textBlock = context.textBlockFactory.buildAtomicTextBlock(mergeAndSortTextPositionSequenceByYThenX(textBlocks), footer, context, page);
List<Integer> tocId = context.tableOfContents().createNewEntryAndReturnId(FOOTER, footer);
footer.setTocId(tocId);
footer.setTerminalTextBlock(textBlock);
page.setFooter(footer);
}
public void addHeader(List<TextBlock> textBlocks, Context context) {
PageNode page = getPage(textBlocks.get(0).getPage(), context);
HeaderNode header = HeaderNode.builder().page(page).tableOfContents(context.tableOfContents()).build();
AtomicTextBlock textBlock = context.textBlockFactory.buildAtomicTextBlock(mergeAndSortTextPositionSequenceByYThenX(textBlocks), header, context, 0, page);
List<Integer> tocId = context.tableOfContents().createNewEntryAndReturnId(HEADER, header);
header.setTocId(tocId);
header.setTerminalTextBlock(textBlock);
page.setHeader(header);
}
private void addEmptyFooter(int pageIndex, Context context) {
PageNode page = getPage(pageIndex, context);
FooterNode footer = FooterNode.builder().page(page).tableOfContents(context.tableOfContents()).build();
AtomicTextBlock textBlock = context.textBlockFactory.emptyTextBlock(footer, context, page);
List<Integer> tocId = context.tableOfContents().createNewEntryAndReturnId(FOOTER, footer);
footer.setTocId(tocId);
footer.setTerminalTextBlock(textBlock);
page.setFooter(footer);
}
private void addEmptyHeader(int pageIndex, Context context) {
PageNode page = getPage(pageIndex, context);
HeaderNode header = HeaderNode.builder().page(page).tableOfContents(context.tableOfContents()).build();
AtomicTextBlock textBlock = context.textBlockFactory.emptyTextBlock(header, context, 0, page);
List<Integer> tocId = context.tableOfContents().createNewEntryAndReturnId(HEADER, header);
header.setTocId(tocId);
header.setTerminalTextBlock(textBlock);
page.setHeader(header);
}
private PageNode buildPage(Page p) {
return PageNode.builder()
.height((int) p.getPageHeight())
.width((int) p.getPageWidth())
.number(p.getPageNumber())
.rotation(p.getRotation())
.mainBody(new LinkedList<>())
.build();
}
private PageNode getPage(int pageIndex, Context context) {
return context.pages.keySet()
.stream()
.filter(page -> page.getNumber() == pageIndex)
.findFirst()
.orElseThrow(() -> new NotFoundException(format("Page with number %d not found", pageIndex)));
}
record Context(
TableOfContents tableOfContents, Map<PageNode, AtomicInteger> pages, List<SectionNode> sections, List<PdfImage> images, TextBlockFactory textBlockFactory) {
}
}

View File

@ -0,0 +1,44 @@
package com.iqser.red.service.redaction.v1.server.document.graph.factory;
import java.awt.geom.Rectangle2D;
import java.util.List;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.AtomicTextBlock;
import com.iqser.red.service.redaction.v1.server.redaction.model.RedRectangle2D;
import com.iqser.red.service.redaction.v1.server.tableextraction.model.AbstractTextContainer;
public class RectangleTransformations {
public static Rectangle2D pad(Rectangle2D rectangle2D, int deltaX, int deltaY) {
return new Rectangle2D.Double(rectangle2D.getMinX() - deltaX, rectangle2D.getMinY() - deltaY, rectangle2D.getWidth() + 2 * deltaX, rectangle2D.getHeight() + 2 * deltaY);
}
public static Rectangle2D bBoxUnionAbstractTextContainer(List<AbstractTextContainer> abstractTextContainers) {
return abstractTextContainers.stream().map(RectangleTransformations::toRectangle2D).reduce((a, b) -> a.createUnion(b).getBounds2D()).orElseThrow();
}
public static Rectangle2D bBoxUnionAtomicTextBlock(List<AtomicTextBlock> atomicTextBlocks) {
return atomicTextBlocks.stream()
.flatMap(atomicTextBlock -> atomicTextBlock.getPositions().stream())
.reduce((a, b) -> a.createUnion(b).getBounds2D())
.orElse(new Rectangle2D.Double());
}
public static Rectangle2D toRectangle2D(AbstractTextContainer abstractTextContainer) {
return new Rectangle2D.Float(abstractTextContainer.getMinX(), abstractTextContainer.getMinY(), abstractTextContainer.getWidth(), abstractTextContainer.getHeight());
}
public static Rectangle2D toRectangle2D(RedRectangle2D redRectangle2D) {
return new Rectangle2D.Double(redRectangle2D.getX(), redRectangle2D.getY(), redRectangle2D.getWidth(), redRectangle2D.getHeight());
}
}

View File

@ -0,0 +1,159 @@
package com.iqser.red.service.redaction.v1.server.document.graph.factory;
import java.awt.geom.AffineTransform;
import java.awt.geom.Rectangle2D;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Objects;
import org.springframework.stereotype.Service;
import com.iqser.red.service.redaction.v1.server.parsing.model.RedTextPosition;
import com.iqser.red.service.redaction.v1.server.parsing.model.TextDirection;
import com.iqser.red.service.redaction.v1.server.parsing.model.TextPositionSequence;
@Service
public class SearchTextWithTextPositionFactory {
public static final int HEIGHT_PADDING = 2;
public static SearchTextWithTextPositionModel buildSearchTextToTextPositionModel(List<TextPositionSequence> sequences) {
if (sequences.isEmpty() || sequences.stream().allMatch(sequence -> sequence.getTextPositions().isEmpty())) {
return SearchTextWithTextPositionModel.builder()
.searchText("")
.lineBreaks(Collections.emptyList())
.positions(Collections.emptyList())
.stringCoordsToPositionCoords(Collections.emptyList())
.build();
}
List<Integer> stringIdxToPositionIdx = new LinkedList<>();
List<Integer> lineBreaksStringIdx = new LinkedList<>();
StringBuilder sb = new StringBuilder();
int stringIdx = 0;
int positionIdx = 0;
int lastHyphenIdx = -3;
RedTextPosition currentTextPosition = sequences.get(0).getTextPositions().get(0);
RedTextPosition previousTextPosition = RedTextPosition.builder().unicode(" ").position(currentTextPosition.getPosition()).build();
for (TextPositionSequence word : sequences) {
for (int i = 0; i < word.getTextPositions().size(); ++i) {
currentTextPosition = word.getTextPositions().get(i);
if (isLineBreak(currentTextPosition, previousTextPosition)) {
if (stringIdx - lastHyphenIdx < 3) {
sb.replace(lastHyphenIdx, sb.length(), "");
stringIdxToPositionIdx = stringIdxToPositionIdx.subList(0, lastHyphenIdx);
stringIdx = lastHyphenIdx;
lastHyphenIdx = -3;
} else {
lineBreaksStringIdx.add(stringIdx);
}
}
if (!isRepeatedWhitespace(currentTextPosition.getUnicode(), previousTextPosition.getUnicode())) {
if (isHyphen(currentTextPosition.getUnicode())) {
lastHyphenIdx = stringIdx;
}
sb.append(currentTextPosition.getUnicode());
stringIdxToPositionIdx.add(positionIdx);
++stringIdx;
}
previousTextPosition = currentTextPosition;
++positionIdx;
}
previousTextPosition = RedTextPosition.builder().unicode(" ").position(previousTextPosition.getPosition()).build();
sb.append(previousTextPosition.getUnicode());
stringIdxToPositionIdx.add(positionIdx);
++stringIdx;
}
assert sb.length() == stringIdxToPositionIdx.size();
List<Rectangle2D> positions = sequences.stream()//
.flatMap(sequence -> sequence.getTextPositions().stream().map(textPosition -> mapRedTextPositionToUserSpace(textPosition, sequence))).toList();
return SearchTextWithTextPositionModel.builder()
.searchText(sb.toString())
.lineBreaks(lineBreaksStringIdx)
.stringCoordsToPositionCoords(stringIdxToPositionIdx)
.positions(positions)
.build();
}
private static boolean isLineBreak(RedTextPosition currentTextPosition, RedTextPosition previousTextPosition) {
return Objects.equals(currentTextPosition.getUnicode(), "\n") || isDeltaYLargerThanTextHeight(currentTextPosition, previousTextPosition);
}
private static boolean isDeltaYLargerThanTextHeight(RedTextPosition currentPosition, RedTextPosition previousPosition) {
if (previousPosition == null) {
return false;
}
float deltaY = Math.abs(currentPosition.getYDirAdj() - previousPosition.getYDirAdj());
return deltaY >= currentPosition.getHeightDir();
}
private static boolean isRepeatedWhitespace(String currentUnicode, String previousUnicode) {
return Objects.equals(previousUnicode, " ") && Objects.equals(currentUnicode, " ");
}
private static boolean isHyphen(String unicodeCharacter) {
return Objects.equals(unicodeCharacter, "-") || //
Objects.equals(unicodeCharacter, "~") || //
Objects.equals(unicodeCharacter, "") || //
Objects.equals(unicodeCharacter, "") || //
Objects.equals(unicodeCharacter, "") || //
Objects.equals(unicodeCharacter, "") || //
Objects.equals(unicodeCharacter, "") || //
Objects.equals(unicodeCharacter, "") || //
Objects.equals(unicodeCharacter, "") || //
Objects.equals(unicodeCharacter, "") || //
Objects.equals(unicodeCharacter, "\u00AD");
}
private static Rectangle2D mapRedTextPositionToUserSpace(RedTextPosition textPosition, TextPositionSequence sequence) {
float textHeight = sequence.getTextHeight() + HEIGHT_PADDING;
Rectangle2D rectangle2D = new Rectangle2D.Double(textPosition.getXDirAdj(),
textPosition.getYDirAdj() - textHeight,
textPosition.getWidthDirAdj(),
textHeight + HEIGHT_PADDING);
AffineTransform transform = new AffineTransform();
if (sequence.getDir() == TextDirection.ZERO || sequence.getDir() == TextDirection.HALF_CIRCLE) {
transform.rotate(sequence.getDir().getRadians(), sequence.getPageWidth() / 2f, sequence.getPageHeight() / 2f);
transform.translate(0f, sequence.getPageHeight());
} else if (sequence.getDir() == TextDirection.QUARTER_CIRCLE) {
transform.rotate(sequence.getDir().getRadians(), sequence.getPageWidth() / 2f, sequence.getPageWidth() / 2f);
transform.translate(0f, sequence.getPageWidth());
} else {
transform.rotate(sequence.getDir().getRadians(), sequence.getPageHeight() / 2f, sequence.getPageHeight() / 2f);
transform.translate(0f, sequence.getPageWidth());
}
transform.scale(1., -1.);
return transform.createTransformedShape(rectangle2D).getBounds2D();
}
}

View File

@ -1,5 +1,6 @@
package com.iqser.red.service.redaction.v1.server.redaction.model;
package com.iqser.red.service.redaction.v1.server.document.graph.factory;
import java.awt.geom.Rectangle2D;
import java.util.List;
import lombok.AccessLevel;
@ -15,5 +16,5 @@ public class SearchTextWithTextPositionModel {
String searchText;
List<Integer> lineBreaks;
List<Integer> stringCoordsToPositionCoords;
List<RedRectangle2D> positions;
List<Rectangle2D> positions;
}

View File

@ -0,0 +1,75 @@
package com.iqser.red.service.redaction.v1.server.document.graph.factory;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import com.iqser.red.service.redaction.v1.server.document.graph.Boundary;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.PageNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.SemanticNode;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.AtomicTextBlock;
import com.iqser.red.service.redaction.v1.server.parsing.model.TextPositionSequence;
public class TextBlockFactory {
AtomicInteger stringOffset;
AtomicLong textBlockIdx;
public TextBlockFactory() {
stringOffset = new AtomicInteger();
textBlockIdx = new AtomicLong();
}
public AtomicTextBlock buildAtomicTextBlock(List<TextPositionSequence> sequences, SemanticNode parent, DocumentGraphFactory.Context context, PageNode page) {
Integer numberOnPage = context.pages().get(page).getAndIncrement();
return buildAtomicTextBlock(sequences, parent, context, numberOnPage, page);
}
public AtomicTextBlock buildAtomicTextBlock(List<TextPositionSequence> sequences, SemanticNode parent, DocumentGraphFactory.Context context, Integer numberOnPage, PageNode page) {
SearchTextWithTextPositionModel searchTextWithTextPositionModel = SearchTextWithTextPositionFactory.buildSearchTextToTextPositionModel(sequences);
int offset = stringOffset.getAndAdd(searchTextWithTextPositionModel.getSearchText().length());
return AtomicTextBlock.builder()
.id(textBlockIdx.getAndIncrement())
.parent(parent)
.searchText(searchTextWithTextPositionModel.getSearchText())
.numberOnPage(numberOnPage)
.page(page)
.lineBreaks(searchTextWithTextPositionModel.getLineBreaks())
.positions(searchTextWithTextPositionModel.getPositions())
.stringIdxToPositionIdx(searchTextWithTextPositionModel.getStringCoordsToPositionCoords())
.boundary(new Boundary(offset, offset + searchTextWithTextPositionModel.getSearchText().length()))
.build();
}
public AtomicTextBlock emptyTextBlock(SemanticNode parent, DocumentGraphFactory.Context context, PageNode page) {
return emptyTextBlock(parent, context, context.pages().get(page).getAndIncrement(), page);
}
public AtomicTextBlock emptyTextBlock(SemanticNode parent, DocumentGraphFactory.Context context, Integer numberOnPage, PageNode page) {
return AtomicTextBlock.builder()
.id(textBlockIdx.getAndIncrement())
.boundary(new Boundary(stringOffset.get(), stringOffset.get()))
.searchText("")
.lineBreaks(Collections.emptyList())
.page(page)
.numberOnPage(numberOnPage)
.stringIdxToPositionIdx(Collections.emptyList())
.positions(Collections.emptyList())
.parent(parent)
.build();
}
}

View File

@ -0,0 +1,22 @@
package com.iqser.red.service.redaction.v1.server.document.graph.factory;
import java.util.Comparator;
import java.util.List;
import com.iqser.red.service.redaction.v1.server.classification.model.TextBlock;
import com.iqser.red.service.redaction.v1.server.parsing.model.TextPositionSequence;
public class TextPositionOperations {
public static List<TextPositionSequence> mergeAndSortTextPositionSequenceByYThenX(List<TextBlock> textBlocks) {
return textBlocks
.stream()
.flatMap(tb -> tb.getSequences().stream())
.sorted(Comparator.comparingDouble(TextPositionSequence::getMaxYDirAdj)//
.thenComparing(TextPositionSequence::getMaxXDirAdj))
.toList();
}
}

View File

@ -1,219 +0,0 @@
package com.iqser.red.service.redaction.v1.server.document.graph.nodes;
import static com.iqser.red.service.redaction.v1.server.document.services.EntityEnrichmentUtility.enrichEntity;
import static java.lang.String.format;
import java.util.Set;
import java.util.stream.Stream;
import com.iqser.red.service.redaction.v1.server.document.graph.Boundary;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.AtomicTextBlock;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlock;
import com.iqser.red.service.redaction.v1.server.exception.NotFoundException;
import com.iqser.red.service.redaction.v1.server.redaction.model.EntityType;
public interface DocumentGraphNode {
/**
* Searches all Nodes located underneath this Node in the TableOfContents and concatenates their AtomicTextBlocks into a single TextBlockEntity.
* So, for a Section all AtomicTextBlocks of Subsections, Paragraphs, and Tables are concatenated into a single TextBlockEntity
*
* @return TextBlock containing all AtomicTextBlocks that are located under this Node.
*/
TextBlock buildTextBlock();
/**
* Any Node maintains its own Set of Entities.
* This Set contains all Entities, whose first index is located in any of the AtomicTextBlocks underneath this Node.
*
* @return Set of all Entities associated with this Node
*/
Set<EntityNode> getEntities();
/**
* Returns the PageNode associated with this Node.
* If the node has more than one PageNode associated, it returns the PageNode with the lowest number.
* For example a section might span multiple pages, it then returns the page where the section starts.
*
* @return PageNode representing the first page on which the Node is located in the document
*/
PageNode getPage();
/**
* Any Node except the First level of Sections, Header, Footer, and Pages have a direct Parent.
* For example a Paragraph has a parent Section, a Table Cell has a parent Table, etc...
* hasParent() may be used to check whether a parent is present.
*
* @return Node that represents the Parent or null, if no parent is present.
*/
DocumentGraphNode getParent();
Stream<DocumentGraphNode> streamAllSubNodes();
/**
* Each AtomicTextBlock has a number assigned per page, this returns the number of the first AtomicTextBlock underneath this node
*
* @return Integer representing the number on the page
*/
Integer getNumberOnPage();
/**
*
* @return the fist headline whent traversing the tree upwards
*/
default CharSequence getHeadline() {
return getParent().getHeadline();
}
/**
* By default, a parent is always present, this needs to be overwritten for Headers, Footers, Pages, and Sections.
*
* @return boolean, indicating whether a parent is present.
*/
default boolean hasParent() {
return true;
}
/**
* by default a Node does not have direct access to an AtomicTextBlock
*
* @return boolean, indicating if a Node has direct access to an AtomicTextBlock
*/
default boolean isTerminal() {
return false;
}
/**
* by default a Node does not have direct access to an AtomicTextBlock, this method throws a UnsupportedOperationException if not overridden.
*
* @return AtomicTextBlock
*/
default AtomicTextBlock getAtomicTextBlock() {
throw new UnsupportedOperationException("Only terminal Nodes have access to AtomicTextBlocks!");
}
/**
* creates an EntityNode with only initial values set, inserts it into the subgraph contained by this node and sets the inferrable fields.
* Throws NotFoundException and removes the entity if the provided boundary could not be found in the subgraph.
*
* @param boundary start and end indices in String coordinates of the entity to be created
* @param type type of the entity to be created
* @param entityType entityType of the entity to be created
* @return the newly created and inserted EntityNode with all fields set.
*/
default EntityNode createAndAddEntity(Boundary boundary, String type, EntityType entityType) {
EntityNode entity = EntityNode.initialEntityNode(boundary, type, entityType);
addEntityToNodeAndSetFields(entity);
return entity;
}
/**
* searches for the first terminal Node containing the start index of the entity to be inserted.
* Catches NotFoundException to remove the EntityNode from the graph, then rethrows it
*
* @param entity newly created EntityNode with only initial values set
*/
default void addEntityToNodeAndSetFields(EntityNode entity) {
try {
streamAllSubNodes().anyMatch(node -> node.addEntityAndSetFieldsIfStartIndexContained(entity));
} catch (NotFoundException e) {
entity.removeFromGraph();
throw new RuntimeException(e);
}
}
/**
* If this Node's AtomicTextBlock contains the start index of the entity, the entity's position is read from the AtomicTextBlock.
* If the position can not be fully read from the AtomicTextBlock, it recursively looks in the TextBlocks of the parents until all positions are found.
* Further, the function throws NotFoundException if no parent contains all positions.
* This occurs, when the Entity is in between Nodes that do not share a parent, e.g. main sections.
* Finally, the function adds the Entity to its own list of Entities and to every parents' list recursively.
*
* @param entity The entity to be added to the graph
* @return true, if the entity has been added successfully.
* false, if the entity's start index is not contained or the Node doesn't have an AtomicTextBlock
*/
default boolean addEntityAndSetFieldsIfStartIndexContained(EntityNode entity) {
if (!isTerminal()) {
return false;
}
AtomicTextBlock atomicTextBlock = getAtomicTextBlock();
if (atomicTextBlock.containsIndex(entity.getBoundary().start())) {
entity.addContainingNode(this);
getEntities().add(entity);
addEntityToPage(entity);
addEntityToParents(entity);
setFields(entity, atomicTextBlock);
return true;
}
return false;
}
private void addEntityToPage(EntityNode entity) {
getPage().getEntities().add(entity);
entity.setPage(getPage());
}
private void setFields(EntityNode entity, AtomicTextBlock atomicTextBlock) {
if (atomicTextBlock.containsBoundary(entity.getBoundary())) {
enrichEntity(entity, atomicTextBlock);
} else {
this.setFieldsFromParents(this, entity);
}
}
private void addEntityToParents(EntityNode entity) {
DocumentGraphNode node = this;
while (node.hasParent()) {
node = node.getParent();
node.getEntities().add(entity);
entity.addContainingNode(node);
}
}
private void setFieldsFromParents(DocumentGraphNode node, EntityNode entity) {
if (node.hasParent()) {
DocumentGraphNode parent = node.getParent();
TextBlock textBlock = parent.buildTextBlock();
if (textBlock.containsBoundary(entity.getBoundary())) {
enrichEntity(entity, textBlock);
return;
} else {
setFieldsFromParents(parent, entity);
}
}
throw new NotFoundException(format("Position could not be found for Entity %s", entity.toString()));
}
}

View File

@ -3,6 +3,7 @@ package com.iqser.red.service.redaction.v1.server.document.graph.nodes;
import java.awt.geom.Rectangle2D;
import java.nio.charset.StandardCharsets;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
@ -60,42 +61,65 @@ public class EntityNode {
@Builder.Default
String legalBasis = "";
// inferrable from graph
// inferred on graph insertion
String value;
CharSequence textBefore;
CharSequence textAfter;
PageNode page;
@Builder.Default
Set<PageNode> pages = new HashSet<>();
List<Rectangle2D> positions;
@Builder.Default
Set<DocumentGraphNode> containingNodes = new HashSet<>();
List<SemanticNode> intersectingNodes = new LinkedList<>();
SemanticNode deepestFullyContainingNode;
public void addContainingNode(DocumentGraphNode containingNode) {
public void addIntersectingNode(SemanticNode containingNode) {
containingNodes.add(containingNode);
intersectingNodes.add(containingNode);
}
public void removeFromGraph() {
getContainingNodes().forEach(node -> node.getEntities().remove(this));
getPage().getEntities().remove(this);
getIntersectingNodes().forEach(node -> node.getEntities().remove(this));
deepestFullyContainingNode = null;
getPages().forEach(page -> page.getEntities().remove(this));
setRemoved(true);
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("Entity [");
sb.append(value);
sb.append(", ");
sb.append(boundary);
sb.append(", Pages[");
pages.forEach(page -> {
sb.append(page.getNumber());
sb.append(", ");
});
sb.delete(sb.length() - 2, sb.length());
sb.append("], containingNode ");
sb.append(deepestFullyContainingNode);
return sb.toString();
}
@Override
public int hashCode() {
var sb = new StringBuilder();
StringBuilder sb = new StringBuilder();
sb.append("Entity [");
sb.append(value);
sb.append(boundary.start());
sb.append(page.getNumber());
positions.forEach(r -> {
sb.append(r.getMinX());
sb.append(r.getMinY());
sb.append(r.getWidth());
sb.append(r.getHeight());
sb.append(", ");
sb.append(boundary);
sb.append(", Pages[");
pages.forEach(page -> {
sb.append(page.getNumber());
sb.append(", ");
});
return Hashing.murmur3_128().hashString(sb.toString(), StandardCharsets.UTF_8).hashCode();
}

View File

@ -0,0 +1,64 @@
package com.iqser.red.service.redaction.v1.server.document.graph.nodes;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import com.iqser.red.service.redaction.v1.server.document.graph.PageElement;
import com.iqser.red.service.redaction.v1.server.document.graph.TableOfContents;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlock;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.FieldDefaults;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE)
public class FooterNode extends PageElement implements SemanticNode {
List<Integer> tocId;
TextBlock terminalTextBlock;
@Builder.Default
boolean terminal = true;
@EqualsAndHashCode.Exclude
PageNode page;
@EqualsAndHashCode.Exclude
TableOfContents tableOfContents;
@Builder.Default
@EqualsAndHashCode.Exclude
Set<EntityNode> entities = new HashSet<>();
@Override
public TextBlock buildTextBlock() {
return terminalTextBlock;
}
@Override
public Set<PageNode> getPages() {
return Collections.singleton(page);
}
@Override
public String toString() {
return tocId + ": " + NodeType.FOOTER + ": " + terminalTextBlock.getSearchText();
}
}

View File

@ -0,0 +1,62 @@
package com.iqser.red.service.redaction.v1.server.document.graph.nodes;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import com.iqser.red.service.redaction.v1.server.document.graph.PageElement;
import com.iqser.red.service.redaction.v1.server.document.graph.TableOfContents;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlock;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.FieldDefaults;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE)
public class HeaderNode extends PageElement implements SemanticNode {
List<Integer> tocId;
TextBlock terminalTextBlock;
@Builder.Default
boolean terminal = true;
@EqualsAndHashCode.Exclude
PageNode page;
@EqualsAndHashCode.Exclude
TableOfContents tableOfContents;
@Builder.Default
@EqualsAndHashCode.Exclude
Set<EntityNode> entities = new HashSet<>();
@Override
public TextBlock buildTextBlock() {
return terminalTextBlock;
}
@Override
public Set<PageNode> getPages() {
return Collections.singleton(page);
}
@Override
public String toString() {
return tocId + ": " + NodeType.HEADER + ": " + terminalTextBlock.getSearchText();
}
}

View File

@ -0,0 +1,63 @@
package com.iqser.red.service.redaction.v1.server.document.graph.nodes;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import com.iqser.red.service.redaction.v1.server.document.graph.PageElement;
import com.iqser.red.service.redaction.v1.server.document.graph.TableOfContents;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlock;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.FieldDefaults;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE)
public class HeadlineNode extends PageElement implements SemanticNode {
List<Integer> tocId;
TextBlock terminalTextBlock;
@Builder.Default
boolean terminal = true;
@EqualsAndHashCode.Exclude
TableOfContents tableOfContents;
@EqualsAndHashCode.Exclude
Set<PageNode> pages;
@Builder.Default
@EqualsAndHashCode.Exclude
Set<EntityNode> entities = new HashSet<>();
@Override
public TextBlock buildTextBlock() {
return terminalTextBlock;
}
@Override
public String toString() {
return tocId + ": " + NodeType.HEADLINE + ": " + terminalTextBlock.toString();
}
@Override
public SemanticNode getHeadline() {
return this;
}
}

View File

@ -0,0 +1,88 @@
package com.iqser.red.service.redaction.v1.server.document.graph.nodes;
import java.awt.geom.Rectangle2D;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.iqser.red.service.redaction.v1.server.document.graph.PageElement;
import com.iqser.red.service.redaction.v1.server.document.graph.TableOfContents;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlock;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlockCollector;
import com.iqser.red.service.redaction.v1.server.redaction.model.ImageType;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.FieldDefaults;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE)
public class ImageNode extends PageElement implements SemanticNode {
List<Integer> tocId;
ImageType imageType;
boolean transparency;
Rectangle2D position;
@Builder.Default
boolean redaction = false;
@Builder.Default
boolean ignored = false;
@Builder.Default
String redactionReason = "";
@Builder.Default
String legalBasis = "";
@Builder.Default
int matchedRule = -1;
@EqualsAndHashCode.Exclude
PageNode page;
@EqualsAndHashCode.Exclude
TableOfContents tableOfContents;
@Builder.Default
@EqualsAndHashCode.Exclude
Set<EntityNode> entities = new HashSet<>();
@Override
public TextBlock buildTextBlock() {
return streamAllSubNodes().filter(SemanticNode::isTerminal).map(SemanticNode::getTerminalTextBlock).collect(new TextBlockCollector());
}
@Override
public Set<PageNode> getPages() {
return Collections.singleton(page);
}
@Override
public String toString() {
return tocId + ": " + NodeType.IMAGE + ": " + imageType.toString() + " " + position;
}
@Override
public Map<PageNode, Rectangle2D> getBBox() {
Map<PageNode, Rectangle2D> bBoxPerPage = new HashMap<>();
bBoxPerPage.put(page, position);
return bBoxPerPage;
}
}

View File

@ -7,6 +7,11 @@ public enum NodeType {
return "Section";
}
},
HEADLINE {
public String toString() {
return "Headline";
}
},
PARAGRAPH {
public String toString() {
@ -24,5 +29,23 @@ public enum NodeType {
return "Cell";
}
},
IMAGE {
public String toString() {
return "Image";
}
},
HEADER {
public String toString() {
return "Header";
}
},
FOOTER {
public String toString() {
return "Footer";
}
}
}

View File

@ -3,83 +3,68 @@ package com.iqser.red.service.redaction.v1.server.document.graph.nodes;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Stream;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.AtomicTextBlock;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.ConcatenatedTextBlock;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.SemanticNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.EntityNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.FooterNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.HeaderNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.ImageNode;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlock;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlockCollector;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.FieldDefaults;
@Data
@Getter
@Setter
@Builder
@AllArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE)
public class PageNode implements DocumentGraphNode{
public class PageNode {
Integer number;
Integer height;
Integer width;
List<DocumentGraphNode> mainBody;
AtomicTextBlock header;
AtomicTextBlock footer;
Integer rotation;
@EqualsAndHashCode.Exclude
List<SemanticNode> mainBody;
@EqualsAndHashCode.Exclude
HeaderNode header;
@EqualsAndHashCode.Exclude
FooterNode footer;
@Builder.Default
@EqualsAndHashCode.Exclude
Set<EntityNode> entities = new HashSet<>();
@Builder.Default
@EqualsAndHashCode.Exclude
Set<ImageNode> images = new HashSet<>();
public ConcatenatedTextBlock buildTextBlock() {
public TextBlock getMainBodyTextBlock() {
return mainBody.stream().filter(DocumentGraphNode::isTerminal).map(DocumentGraphNode::getAtomicTextBlock).collect(new TextBlockCollector());
}
@Override
public PageNode getPage() {
return this;
}
@Override
public DocumentGraphNode getParent() {
return null;
}
@Override
public boolean hasParent() {
return false;
}
@Override
public Stream<DocumentGraphNode> streamAllSubNodes() {
return mainBody.stream();
}
@Override
public Integer getNumberOnPage() {
return 0;
return mainBody.stream().filter(SemanticNode::isTerminal).map(SemanticNode::getTerminalTextBlock).collect(new TextBlockCollector());
}
@Override
public String toString() {
return header.getSearchText() + buildTextBlock().toString() + footer.getSearchText();
return String.valueOf(number);
}
@Override
public int hashCode() {
return number;
}
}

View File

@ -1,10 +1,12 @@
package com.iqser.red.service.redaction.v1.server.document.graph.nodes;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Stream;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.AtomicTextBlock;
import com.iqser.red.service.redaction.v1.server.document.graph.PageElement;
import com.iqser.red.service.redaction.v1.server.document.graph.TableOfContents;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlock;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
@ -17,17 +19,19 @@ import lombok.experimental.FieldDefaults;
@Builder
@AllArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE)
public class ParagraphNode implements DocumentGraphNode {
public class ParagraphNode extends PageElement implements SemanticNode {
String tocId;
Integer numberOnPage;
Integer numberInSection;
List<Integer> tocId;
TextBlock terminalTextBlock;
@Builder.Default
boolean terminal = true;
@EqualsAndHashCode.Exclude
SectionNode parentSection;
TableOfContents tableOfContents;
@EqualsAndHashCode.Exclude
PageNode page;
AtomicTextBlock atomicTextBlock;
Set<PageNode> pages;
@Builder.Default
@EqualsAndHashCode.Exclude
@ -35,36 +39,14 @@ public class ParagraphNode implements DocumentGraphNode {
@Override
public AtomicTextBlock buildTextBlock() {
public TextBlock buildTextBlock() {
return atomicTextBlock;
return terminalTextBlock;
}
@Override
public DocumentGraphNode getParent() {
return parentSection;
}
@Override
public boolean isTerminal() {
return true;
}
@Override
public String toString() {
return tocId + ": " + atomicTextBlock.toString();
return tocId + ": " + NodeType.PARAGRAPH + ": " + terminalTextBlock.toString();
}
@Override
public Stream<DocumentGraphNode> streamAllSubNodes() {
return Stream.of(this);
}
}

View File

@ -3,12 +3,12 @@ package com.iqser.red.service.redaction.v1.server.document.graph.nodes;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Stream;
import com.iqser.red.service.redaction.v1.server.document.graph.PageElement;
import com.iqser.red.service.redaction.v1.server.document.graph.TableOfContents;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.AtomicTextBlock;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.ConcatenatedTextBlock;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlock;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlockCollector;
import com.iqser.red.service.redaction.v1.server.exception.NotFoundException;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
@ -23,86 +23,44 @@ import lombok.extern.slf4j.Slf4j;
@Builder
@AllArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE)
public class SectionNode implements DocumentGraphNode {
public class SectionNode extends PageElement implements SemanticNode {
String tocId;
Integer numberOnPage;
List<Integer> tocId;
TextBlock textBlock;
@EqualsAndHashCode.Exclude
TableOfContents tableOfContents;
@EqualsAndHashCode.Exclude
DocumentGraphNode parentSection;
AtomicTextBlock headline;
List<SectionNode> subSections;
List<ParagraphNode> paragraphs;
List<TableNode> tables;
@EqualsAndHashCode.Exclude
List<PageNode> pages;
Set<PageNode> pages;
@Builder.Default
@EqualsAndHashCode.Exclude
Set<EntityNode> entities = new HashSet<>();
@Override
public TextBlock buildTextBlock() {
if (textBlock == null) {
textBlock = streamAllSubNodes().filter(SemanticNode::isTerminal).map(SemanticNode::getTerminalTextBlock).collect(new TextBlockCollector());
}
return textBlock;
}
@Override
public String toString() {
return tocId + ": " + headline.toString();
return tocId.toString() + ": " + NodeType.SECTION + ": " + buildTextBlock().getSearchText();
}
@Override
public ConcatenatedTextBlock buildTextBlock() {
public HeadlineNode getHeadline() {
return streamAllSubNodes().map(DocumentGraphNode::getAtomicTextBlock).collect(new TextBlockCollector());
}
@Override
public DocumentGraphNode getParent() {
if (hasParent()) {
return parentSection;
} else {
throw new UnsupportedOperationException("This section has no parent Section!");
}
}
@Override
public Stream<DocumentGraphNode> streamAllSubNodes() {
return tableOfContents.streamSubEntriesInOrder(tocId).map(TableOfContents.Entry::node);
}
@Override
public boolean hasParent() {
return parentSection != null;
}
@Override
public boolean isTerminal() {
return true;
}
@Override
public AtomicTextBlock getAtomicTextBlock() {
return headline;
}
@Override
public PageNode getPage() {
return pages.get(0);
return streamChildren().filter(node -> node instanceof HeadlineNode)
.map(node -> (HeadlineNode) node)
.findFirst()
.orElseThrow(() -> new NotFoundException("Section has no Headline!"));
}
}

View File

@ -0,0 +1,162 @@
package com.iqser.red.service.redaction.v1.server.document.graph.nodes;
import static java.util.stream.Collectors.groupingBy;
import java.awt.geom.Rectangle2D;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Stream;
import com.iqser.red.service.redaction.v1.server.document.graph.TableOfContents;
import com.iqser.red.service.redaction.v1.server.document.graph.factory.RectangleTransformations;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.AtomicTextBlock;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlock;
public interface SemanticNode {
/**
* Searches all Nodes located underneath this Node in the TableOfContents and concatenates their AtomicTextBlocks into a single TextBlockEntity.
* So, for a Section all TextBlocks of Subsections, Paragraphs, and Tables are concatenated into a single TextBlockEntity
*
* @return TextBlock containing all AtomicTextBlocks that are located under this Node.
*/
TextBlock buildTextBlock();
/**
* Any Node maintains its own Set of Entities.
* This Set contains all Entities, whose boundary intersects the boundary of the entity.
*
* @return Set of all Entities associated with this Node
*/
Set<EntityNode> getEntities();
Set<PageNode> getPages();
default PageNode getFirstPage() {
return getPages().stream().min(Comparator.comparingInt(PageNode::getNumber)).orElseThrow();
}
TableOfContents getTableOfContents();
List<Integer> getTocId();
void setTocId(List<Integer> tocId);
/**
* Traverses the Tree up, until it hits a HeadlineNode or hits a SectionNode which will then return its HeadlineNode.
*
* @return First HeadlineNode found
*/
default SemanticNode getHeadline() {
return getParent().getHeadline();
}
default boolean hasParent() {
return getTableOfContents().hasParentById(getTocId());
}
default SemanticNode getParent() {
return getTableOfContents().getParentEntryById(getTocId()).node();
}
/**
* Terminal means a SemanticNode has direct access to a TextBlock, by default this is false and must be overridden.
* Currently only Sections and Tables are not terminal.
*
* @return boolean, indicating if a Node has direct access to a TextBlock
*/
default boolean isTerminal() {
return false;
}
/**
* Terminal means a SemanticNode has direct access to a TextBlock, by default this is false and must be overridden.
* Currently only Sections and Tables are not terminal.
*
* @return AtomicTextBlock
*/
default TextBlock getTerminalTextBlock() {
throw new UnsupportedOperationException("Only terminal Nodes have access to TerminalTextBlocks!");
}
/**
* Each AtomicTextBlock has an index on its page, this returns the number of the first AtomicTextBlock underneath this node.
* If this node does not have any AtomicTexBlocks underneath it, e.g. an empty TableCell. It returns -1.
*
* @return Integer representing the number on the page
*/
default Integer getNumberOnPage() {
TextBlock textBlock = buildTextBlock();
if (textBlock.getAtomicTextBlocks().size() > 0) {
return buildTextBlock().getAtomicTextBlocks().get(0).getNumberOnPage();
} else {
return -1;
}
}
default void addThisToEntityIfIntersects(EntityNode entity) {
TextBlock textBlock = buildTextBlock();
if (textBlock.getBoundary().intersects(entity.getBoundary())) {
if (textBlock.containsBoundary(entity.getBoundary())) {
entity.setDeepestFullyContainingNode(this);
}
entity.addIntersectingNode(this);
streamChildren().forEach(node -> node.addThisToEntityIfIntersects(entity));
}
}
default Stream<SemanticNode> streamChildren() {
return getTableOfContents().streamChildren(getTocId());
}
default Stream<SemanticNode> streamAllSubNodes() {
return getTableOfContents().streamSubEntriesInOrder(getTocId()).map(TableOfContents.Entry::node);
}
default Map<PageNode, Rectangle2D> getBBox() {
Map<PageNode, Rectangle2D> bBoxPerPage = new HashMap<>();
if (isTerminal()) {
Map<PageNode, List<AtomicTextBlock>> atomicTextBlockPerPage = buildTextBlock().getAtomicTextBlocks().stream().collect(groupingBy(AtomicTextBlock::getPage));
atomicTextBlockPerPage.forEach((page, atbs) -> bBoxPerPage.put(page, RectangleTransformations.bBoxUnionAtomicTextBlock(atbs)));
return bBoxPerPage;
}
return streamChildren().map(SemanticNode::getBBox).reduce((map1, map2) -> {
map1.forEach((page, rectangle) -> map2.merge(page, rectangle, (rect1, rect2) -> rect1.createUnion(rect2).getBounds2D()));
return map2;
}).orElse(bBoxPerPage);
}
}

View File

@ -1,10 +1,17 @@
package com.iqser.red.service.redaction.v1.server.document.graph.nodes;
import java.util.LinkedList;
import java.awt.geom.Rectangle2D;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Stream;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.AtomicTextBlock;
import com.iqser.red.service.redaction.v1.server.document.graph.PageElement;
import com.iqser.red.service.redaction.v1.server.document.graph.TableOfContents;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlock;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlockCollector;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
@ -17,43 +24,58 @@ import lombok.experimental.FieldDefaults;
@Builder
@AllArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE)
public class TableCellNode implements DocumentGraphNode {
public class TableCellNode extends PageElement implements SemanticNode {
List<Integer> tocId;
int row;
int col;
boolean header;
Rectangle2D bBox;
@Builder.Default
boolean terminal = true;
TextBlock terminalTextBlock;
TextBlock textBlock;
@EqualsAndHashCode.Exclude
TableNode parentTable;
Integer numberOnPage;
AtomicTextBlock atomicTextBlock;
PageNode page;
TableOfContents tableOfContents;
@EqualsAndHashCode.Exclude
Set<PageNode> pages;
@Builder.Default
@EqualsAndHashCode.Exclude
List<EntityNode> entities = new LinkedList<>();
Set<EntityNode> entities = new HashSet<>();
@Override
public AtomicTextBlock buildTextBlock() {
public Map<PageNode, Rectangle2D> getBBox() {
return atomicTextBlock;
}
@Override
public DocumentGraphNode getParent() {
return parentTable;
}
@Override
public boolean isTerminal() {
return true;
Map<PageNode, Rectangle2D> bBoxPerPage = new HashMap<>();
pages.forEach(page -> bBoxPerPage.put(page, bBox));
return bBoxPerPage;
}
@Override
public Stream<DocumentGraphNode> streamAllSubNodes() {
public TextBlock buildTextBlock() {
return Stream.of(this);
if (textBlock == null) {
textBlock = streamAllSubNodes().filter(SemanticNode::isTerminal).map(SemanticNode::getTerminalTextBlock).collect(new TextBlockCollector());
}
return textBlock;
}
@Override
public String toString() {
return tocId + ": " + NodeType.TABLE_CELL + ": " + buildTextBlock().getSearchText();
}
public Stream<TableCellNode> getHeaders() {
TableNode tableNode = (TableNode) getParent();
return tableNode.streamHeadersForCell(row, col);
}
}

View File

@ -1,13 +1,14 @@
package com.iqser.red.service.redaction.v1.server.document.graph.nodes;
import java.util.LinkedList;
import java.util.HashSet;
import java.util.List;
import java.util.function.Function;
import java.util.Set;
import java.util.stream.Stream;
import com.iqser.red.service.redaction.v1.server.document.graph.PageElement;
import com.iqser.red.service.redaction.v1.server.document.graph.TableOfContents;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlock;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlockCollector;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.ConcatenatedTextBlock;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
@ -20,69 +21,55 @@ import lombok.experimental.FieldDefaults;
@Builder
@AllArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE)
public class TableNode implements DocumentGraphNode {
public class TableNode extends PageElement implements SemanticNode {
Integer id;
String tocId;
Integer numberOfRows;
Integer numberOfCols;
Integer numberOnPage;
List<TableCellNode> tableHeaders;
List<List<TableCellNode>> tableCells;
List<Integer> tocId;
TableOfContents tableOfContents;
Integer numberOfRows;
Integer numberOfCols;
TextBlock textBlock;
@EqualsAndHashCode.Exclude
SectionNode parentSection;
@EqualsAndHashCode.Exclude
List<PageNode> pages;
Set<PageNode> pages;
@Builder.Default
@EqualsAndHashCode.Exclude
List<EntityNode> entities = new LinkedList<>();
Set<EntityNode> entities = new HashSet<>();
private Stream<TableCellNode> streamTableCells() {
public Stream<TableCellNode> streamTableCells() {
return tableCells.stream().flatMap(List::stream);
return streamChildren().map(node -> (TableCellNode) node);
}
private Stream<TableCellNode> streamTableRow(int row) {
public Stream<TableCellNode> streamHeaders() {
return tableCells.get(row).stream();
return streamTableCells().filter(TableCellNode::isHeader);
}
private Stream<TableCellNode> streamTableCol(int col) {
public Stream<TableCellNode> streamHeadersForCell(int row, int col) {
return tableCells.stream().map(row -> row.get(col));
return streamHeaders().filter(cell -> cell.getRow() == row || cell.getCol() == col);
}
@Override
public ConcatenatedTextBlock buildTextBlock() {
public TextBlock buildTextBlock() {
return streamTableCells().map(TableCellNode::getAtomicTextBlock).collect(new TextBlockCollector());
}
@Override
public Stream<DocumentGraphNode> streamAllSubNodes() {
return streamTableCells().map(Function.identity());
if (textBlock == null) {
textBlock = streamAllSubNodes().filter(SemanticNode::isTerminal).map(SemanticNode::getTerminalTextBlock).collect(new TextBlockCollector());
}
return textBlock;
}
@Override
public DocumentGraphNode getParent() {
public String toString() {
return parentSection;
}
@Override
public PageNode getPage() {
return pages.get(0);
return tocId.toString() + ": " + NodeType.TABLE + ": " + buildTextBlock().getSearchText();
}
}

View File

@ -6,7 +6,8 @@ import java.awt.geom.Rectangle2D;
import java.util.List;
import com.iqser.red.service.redaction.v1.server.document.graph.Boundary;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.DocumentGraphNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.SemanticNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.PageNode;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
@ -22,6 +23,8 @@ import lombok.experimental.FieldDefaults;
public class AtomicTextBlock implements TextBlock {
Long id;
Integer numberOnPage;
PageNode page;
//string coordinates
Boundary boundary;
@ -33,16 +36,10 @@ public class AtomicTextBlock implements TextBlock {
List<Rectangle2D> positions;
@EqualsAndHashCode.Exclude
DocumentGraphNode parent;
public int indexOf(String searchTerm) {
int pos = searchText.indexOf(searchTerm);
return pos == -1 ? -1 : pos + boundary.start();
}
SemanticNode parent;
@Override
public int numberOfLines() {
return lineBreaks.size();
@ -56,36 +53,38 @@ public class AtomicTextBlock implements TextBlock {
}
@Override
public int getNextLinebreak(int fromIndex) {
return lineBreaks.stream()//
.filter(linebreak -> linebreak > fromIndex) //
.filter(linebreak -> linebreak > fromIndex - boundary.start()) //
.findFirst() //
.orElse(searchText.length()) + boundary.start();
}
@Override
public int getPreviousLinebreak(int fromIndex) {
return lineBreaks.stream()//
.filter(linebreak -> linebreak <= fromIndex)//
.filter(linebreak -> linebreak <= fromIndex - boundary.start())//
.reduce((a, b) -> b)//
.orElse(0) + boundary.start();
}
@Override
public Rectangle2D getPosition(int stringIdx) {
return positions.get(stringIdxToPositionIdx.get(stringIdx - boundary.start()));
}
@Override
public List<Rectangle2D> getPositions(Boundary boundary) {
if (!containsBoundary(boundary)) {
throw new IndexOutOfBoundsException(format("%s is out of bounds for %s",
boundary,
this.boundary));
throw new IndexOutOfBoundsException(format("%s is out of bounds for %s", boundary, this.boundary));
}
if (boundary.end() == this.boundary.end()) {
@ -96,27 +95,6 @@ public class AtomicTextBlock implements TextBlock {
}
@Override
public int length() {
return searchText.length();
}
@Override
public char charAt(int index) {
return searchText.charAt(index - boundary.start());
}
@Override
public CharSequence subSequence(int start, int end) {
return searchText.substring(start - boundary.start(), end - boundary.start());
}
@Override
public String toString() {

View File

@ -18,33 +18,25 @@ import lombok.experimental.FieldDefaults;
public class ConcatenatedTextBlock implements TextBlock, Supplier<ConcatenatedTextBlock> {
List<AtomicTextBlock> atomicTextBlocks;
StringBuilder searchText;
String searchText;
Boundary boundary;
public ConcatenatedTextBlock(List<AtomicTextBlock> atomicTextBlocks) {
this.atomicTextBlocks = new LinkedList<>();
this.searchText = new StringBuilder();
if (atomicTextBlocks.isEmpty()) {
boundary = new Boundary(-1, -1);
return;
}
var firstTextBlock = atomicTextBlocks.get(0);
this.atomicTextBlocks.add(firstTextBlock);
this.searchText.append(firstTextBlock.getSearchText());
boundary = new Boundary(firstTextBlock.getBoundary().start(), firstTextBlock.getBoundary().end());
atomicTextBlocks.subList(1, atomicTextBlocks.size()).forEach(this::concat);
}
public ConcatenatedTextBlock(AtomicTextBlock atomicTextBlocks) {
new ConcatenatedTextBlock(List.of(atomicTextBlocks));
}
public ConcatenatedTextBlock concat(TextBlock textBlock) {
if (this.atomicTextBlocks.isEmpty()) {
@ -53,44 +45,65 @@ public class ConcatenatedTextBlock implements TextBlock, Supplier<ConcatenatedTe
} else if (boundary.end() != textBlock.getBoundary().start()) {
throw new UnsupportedOperationException(format("Can only concat consecutive TextBlocks, trying to concat %s to %s", textBlock.getBoundary(), boundary));
}
this.searchText.append(textBlock.getSearchText());
this.atomicTextBlocks.addAll(textBlock.getAtomicTextBlocks());
boundary.setEnd(textBlock.getBoundary().end());
return this;
}
public int indexOf(String searchTerm) {
private AtomicTextBlock getAtomicTextBlockByStringIndex(int stringIdx) {
int pos = this.searchText.indexOf(searchTerm);
return pos == -1 ? -1 : pos + boundary.start();
return atomicTextBlocks.stream().filter(textBlock -> (textBlock.getBoundary().contains(stringIdx))).findAny().orElseThrow(IndexOutOfBoundsException::new);
}
private List<AtomicTextBlock> getAllAtomicTextBlocksPartiallyInStringIdxRange(Boundary boundary) {
return atomicTextBlocks.stream().filter(tb -> tb.getBoundary().intersects(boundary)).toList();
}
@Override
public String getSearchText() {
if (searchText == null) {
StringBuilder sb = new StringBuilder();
getAtomicTextBlocks().forEach(atb -> sb.append(atb.getSearchText()));
searchText = sb.toString();
}
return searchText;
}
@Override
public int numberOfLines() {
return atomicTextBlocks.stream().map(AtomicTextBlock::getLineBreaks).mapToInt(List::size).sum();
}
@Override
public int getNextLinebreak(int fromIndex) {
return getAtomicTextBlockByStringIndex(fromIndex).getNextLinebreak(fromIndex);
}
@Override
public int getPreviousLinebreak(int fromIndex) {
return getAtomicTextBlockByStringIndex(fromIndex).getPreviousLinebreak(fromIndex);
}
@Override
public Rectangle2D getPosition(int stringIdx) {
return getAtomicTextBlockByStringIndex(stringIdx).getPosition(stringIdx);
}
@Override
public List<Rectangle2D> getPositions(Boundary boundary) {
List<AtomicTextBlock> textBlocks = getAllAtomicTextBlocksPartiallyInStringIdxRange(boundary);
@ -113,43 +126,17 @@ public class ConcatenatedTextBlock implements TextBlock, Supplier<ConcatenatedTe
}
private AtomicTextBlock getAtomicTextBlockByStringIndex(int stringIdx) {
return atomicTextBlocks.stream().filter(textBlock -> (textBlock.getBoundary().end()) > stringIdx).findFirst().orElseThrow(IndexOutOfBoundsException::new);
}
private List<AtomicTextBlock> getAllAtomicTextBlocksPartiallyInStringIdxRange(Boundary boundary) {
return atomicTextBlocks.stream().filter(tb -> tb.getBoundary().intersects(boundary)).toList();
}
@Override
public int length() {
return this.searchText.length();
}
@Override
public char charAt(int index) {
return searchText.charAt(index - boundary.start());
}
@Override
public CharSequence subSequence(int start, int end) {
return searchText.subSequence(start - boundary.start(), end - boundary.start());
}
@Override
public ConcatenatedTextBlock get() {
return this;
}
@Override
public String toString() {
return getSearchText();
}
}

View File

@ -9,7 +9,7 @@ import com.iqser.red.service.redaction.v1.server.document.graph.Boundary;
public interface TextBlock extends CharSequence {
CharSequence getSearchText();
String getSearchText();
List<AtomicTextBlock> getAtomicTextBlocks();
@ -33,7 +33,20 @@ public interface TextBlock extends CharSequence {
int numberOfLines();
int indexOf(String searchTerm);
default int indexOf(String searchTerm) {
return indexOf(searchTerm, getBoundary().start());
}
default int indexOf(String searchTerm, int startOffset) {
int start = getSearchText().indexOf(searchTerm, startOffset - getBoundary().start());
if (start == -1) {
return -1;
}
return start + getBoundary().start();
}
default CharSequence getFirstLine() {
@ -62,4 +75,25 @@ public interface TextBlock extends CharSequence {
return subSequence(boundary.start(), boundary.end());
}
@Override
default CharSequence subSequence(int start, int end) {
return getSearchText().substring(start - getBoundary().start(), end - getBoundary().start());
}
@Override
default int length() {
return getSearchText().length();
}
@Override
default char charAt(int index) {
return getSearchText().charAt(index - getBoundary().start());
}
}

View File

@ -11,7 +11,7 @@ import java.util.stream.Collector;
import lombok.NoArgsConstructor;
@NoArgsConstructor
public class TextBlockCollector implements Collector<AtomicTextBlock, ConcatenatedTextBlock, ConcatenatedTextBlock> {
public class TextBlockCollector implements Collector<TextBlock, ConcatenatedTextBlock, TextBlock> {
@Override
@ -22,7 +22,7 @@ public class TextBlockCollector implements Collector<AtomicTextBlock, Concatenat
@Override
public BiConsumer<ConcatenatedTextBlock, AtomicTextBlock> accumulator() {
public BiConsumer<ConcatenatedTextBlock, TextBlock> accumulator() {
return ConcatenatedTextBlock::concat;
}
@ -36,9 +36,9 @@ public class TextBlockCollector implements Collector<AtomicTextBlock, Concatenat
@Override
public Function<ConcatenatedTextBlock, ConcatenatedTextBlock> finisher() {
public Function<ConcatenatedTextBlock, TextBlock> finisher() {
return Function.identity();
return a -> a;
}

View File

@ -13,13 +13,18 @@ import com.iqser.red.service.redaction.v1.server.document.graph.DocumentGraph;
import com.iqser.red.service.redaction.v1.server.document.graph.TableOfContents;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.PageNode;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.AtomicTextBlock;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlock;
@Service
public class DocumentDataMapper {
public DocumentData toDocumentData(DocumentGraph documentGraph) {
List<AtomicTextBlockData> atomicTextBlockData = documentGraph.streamAtomicTextBlocksInOrder().map(this::toAtomicTextBlockData).toList();
List<AtomicTextBlockData> atomicTextBlockData = documentGraph.streamTextBlocksInOrder()
.flatMap(textBlock -> textBlock.getAtomicTextBlocks().stream())
.distinct()
.map(this::toAtomicTextBlockData)
.toList();
List<PageData> pageData = documentGraph.getPages().stream().map(this::toPageData).toList();
TableOfContentsData tableOfContentsData = toTableOfContentsData(documentGraph.getTableOfContents());
return DocumentData.builder().atomicTextBlocks(atomicTextBlockData).pages(pageData).tableOfContents(tableOfContentsData).build();
@ -34,25 +39,36 @@ public class DocumentDataMapper {
private TableOfContentsData.EntryData toEntryData(TableOfContents.Entry entry) {
Long[] atomicTextBlocks;
if (entry.node().isTerminal()) {
atomicTextBlocks = toAtomicTextBlockIds(entry.node().getTerminalTextBlock());
} else {
atomicTextBlocks = new Long[]{-1L};
}
return TableOfContentsData.EntryData.builder()
.tocId(entry.id())
.tocId(toPrimitiveIntArray(entry.tocId()))
.subEntries(entry.children().stream().map(this::toEntryData).toList())
.type(entry.type())
.atomicTextBlock(entry.node().isTerminal() ? entry.node().getAtomicTextBlock().getId() : -1L)
.page(Long.valueOf(entry.node().getPage().getNumber()))
.atomicTextBlocks(atomicTextBlocks)
.pages(entry.node().getPages().stream().map(PageNode::getNumber).toArray(Long[]::new))
.numberOnPage(entry.node().getNumberOnPage())
.build();
}
private Long[] toAtomicTextBlockIds(TextBlock textBlock) {
return textBlock.getAtomicTextBlocks().stream().map(AtomicTextBlock::getId).toArray(Long[]::new);
}
private PageData toPageData(PageNode pageNode) {
return PageData.builder()
.height(pageNode.getHeight())
.width(pageNode.getWidth())
.number(pageNode.getNumber())
.footer(pageNode.getFooter().getId())
.header(pageNode.getHeader().getId())
.build();
}

View File

@ -1,304 +0,0 @@
package com.iqser.red.service.redaction.v1.server.document.services;
import static java.lang.String.format;
import java.awt.geom.Rectangle2D;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import com.iqser.red.service.redaction.v1.server.classification.model.Document;
import com.iqser.red.service.redaction.v1.server.classification.model.Footer;
import com.iqser.red.service.redaction.v1.server.classification.model.Header;
import com.iqser.red.service.redaction.v1.server.classification.model.Page;
import com.iqser.red.service.redaction.v1.server.classification.model.Section;
import com.iqser.red.service.redaction.v1.server.classification.model.TextBlock;
import com.iqser.red.service.redaction.v1.server.document.graph.Boundary;
import com.iqser.red.service.redaction.v1.server.document.graph.DocumentGraph;
import com.iqser.red.service.redaction.v1.server.document.graph.TableOfContents;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.DocumentGraphNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.NodeType;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.PageNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.ParagraphNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.SectionNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.TableNode;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.AtomicTextBlock;
import com.iqser.red.service.redaction.v1.server.exception.NotFoundException;
import com.iqser.red.service.redaction.v1.server.parsing.model.TextPositionSequence;
import com.iqser.red.service.redaction.v1.server.redaction.model.RedRectangle2D;
import com.iqser.red.service.redaction.v1.server.redaction.model.SearchTextWithTextPositionModel;
import com.iqser.red.service.redaction.v1.server.redaction.service.SearchTextWithTextPositionFactory;
import com.iqser.red.service.redaction.v1.server.tableextraction.model.AbstractTextContainer;
import com.iqser.red.service.redaction.v1.server.tableextraction.model.Table;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class DocumentGraphFactory {
private final SearchTextWithTextPositionFactory searchTextWithTextPositionFactory;
public DocumentGraph buildDocumentGraph(Document document) {
Context context = new Context(new TableOfContents(), new LinkedList<>(), new LinkedList<>(), new AtomicInteger(0), new AtomicLong(0));
context.pages.addAll(document.getPages().stream().map(this::buildPage).toList());
// is tracked by Table of Contents
addSections(document, context);
// not tracked by Table of Contents
addHeaderAndFooterToEachPage(document, context);
DocumentGraph documentGraph = DocumentGraph.builder()
.numberOfPages(context.pages.size())
.pages(context.pages)
.sections(context.sections)
.tableOfContents(context.tableOfContents)
.build();
documentGraph.setText(documentGraph.buildTextBlock());
return documentGraph;
}
private void addSections(Document document, Context context) {
for (var section : document.getSections()) {
addSection(section, context);
}
}
private void addSection(Section section, Context context) {
SectionNode sectionEntity = SectionNode.builder()
.entities(new LinkedList<>())
.pages(new LinkedList<>())
.paragraphs(new LinkedList<>())
.tables(new LinkedList<>())
.subSections(new LinkedList<>())
.tableOfContents(context.tableOfContents())
.build();
context.sections().add(sectionEntity);
List<AbstractTextContainer> pageBlocks = new ArrayList<>(section.getPageBlocks());
PageNode page = getPage(section.getPageBlocks().get(0).getPage(), context);
sectionEntity.getPages().add(page);
page.getMainBody().add(sectionEntity);
if (pageBlocks.get(0) instanceof TextBlock) {
sectionEntity.setHeadline(buildAtomicTextBlock(((TextBlock) pageBlocks.get(0)).getSequences(), sectionEntity, context));
sectionEntity.setNumberOnPage(((TextBlock) pageBlocks.get(0)).getIndexOnPage());
pageBlocks.remove(0);
} else {
sectionEntity.setNumberOnPage(1);
sectionEntity.setHeadline(emptyTextBlock(sectionEntity, context));
}
String sectionId = context.tableOfContents.createNewEntryAndReturnId(NodeType.SECTION, buildSummary(sectionEntity.getHeadline()), sectionEntity);
sectionEntity.setTocId(sectionId);
int paragraphIdx = 0;
int tableIdx = 0;
for (AbstractTextContainer abstractTextContainer : pageBlocks) {
if (abstractTextContainer instanceof TextBlock) {
addParagraph(sectionEntity, (TextBlock) abstractTextContainer, paragraphIdx, context);
paragraphIdx++;
} else if (abstractTextContainer instanceof Table) {
//addTable(sectionEntity, (Table) abstractTextContainer, tableIdx, context);
tableIdx++;
}
}
}
private void addTable(SectionNode sectionEntity, Table table, int tableIdx, Context context) {
PageNode page = getPage(table.getPage(), context);
TableNode tableEntity = TableNode.builder().id(tableIdx).tableOfContents(context.tableOfContents()).pages(new LinkedList<>()).parentSection(sectionEntity).build();
sectionEntity.getTables().add(tableEntity);
if (!page.getMainBody().contains(sectionEntity)) {
sectionEntity.getPages().add(page);
}
page.getMainBody().add(tableEntity);
}
private void addParagraph(SectionNode sectionEntity, TextBlock originalTextBlock, int paragraphIdx, Context context) {
PageNode page = getPage(originalTextBlock.getPage(), context);
ParagraphNode paragraph = ParagraphNode.builder().numberOnPage(originalTextBlock.getIndexOnPage()).page(page).parentSection(sectionEntity).build();
sectionEntity.getParagraphs().add(paragraph);
if (!page.getMainBody().contains(sectionEntity)) {
sectionEntity.getPages().add(page);
}
page.getMainBody().add(paragraph);
var textBlock = buildAtomicTextBlock(originalTextBlock.getSequences(), paragraph, context);
paragraph.setAtomicTextBlock(textBlock);
String tocId = context.tableOfContents.createNewChildEntryAndReturnId(sectionEntity.getTocId(), NodeType.PARAGRAPH, buildSummary(textBlock), paragraph);
paragraph.setTocId(tocId);
}
private void addHeaderAndFooterToEachPage(Document document, Context context) {
Map<Integer, List<TextBlock>> headers = document.getHeaders()
.stream()
.map(Header::getTextBlocks)
.flatMap(List::stream)
.collect(Collectors.groupingBy(AbstractTextContainer::getPage, Collectors.toList()));
Map<Integer, List<TextBlock>> footers = document.getFooters()
.stream()
.map(Footer::getTextBlocks)
.flatMap(List::stream)
.collect(Collectors.groupingBy(AbstractTextContainer::getPage, Collectors.toList()));
for (int pageIndex = 1; pageIndex <= document.getPages().size(); pageIndex++) {
if (headers.containsKey(pageIndex)) {
addHeader(headers.get(pageIndex), context);
} else {
addEmptyHeader(pageIndex, context);
}
}
for (int pageIndex = 1; pageIndex <= document.getPages().size(); pageIndex++) {
if (footers.containsKey(pageIndex)) {
addFooter(footers.get(pageIndex), context);
} else {
addEmptyFooter(pageIndex, context);
}
}
}
private void addFooter(List<TextBlock> textBlocks, Context context) {
PageNode page = getPage(textBlocks.get(0).getPage(), context);
AtomicTextBlock footer = buildAtomicTextBlock(mergeAndSortTextPositionSequences(textBlocks), page, context);
page.setFooter(footer);
}
public void addHeader(List<TextBlock> textBlocks, Context context) {
PageNode page = getPage(textBlocks.get(0).getPage(), context);
AtomicTextBlock header = buildAtomicTextBlock(mergeAndSortTextPositionSequences(textBlocks), page, context);
page.setHeader(header);
}
private void addEmptyFooter(int pageIndex, Context context) {
PageNode page = getPage(pageIndex, context);
page.setFooter(emptyTextBlock(page, context));
}
private void addEmptyHeader(int pageIndex, Context context) {
PageNode page = getPage(pageIndex, context);
page.setHeader(emptyTextBlock(page, context));
}
private AtomicTextBlock emptyTextBlock(DocumentGraphNode parent, Context context) {
return AtomicTextBlock.builder()
.id(context.textBlockIdx.getAndIncrement())
.boundary(new Boundary(context.stringOffset.get(), context.stringOffset.get()))
.searchText("")
.lineBreaks(Collections.emptyList())
.stringIdxToPositionIdx(Collections.emptyList())
.positions(Collections.emptyList())
.parent(parent)
.build();
}
private static String buildSummary(AtomicTextBlock textBlock) {
if (textBlock == null) {
return " probably a table";
}
String[] words = textBlock.getFirstLine().toString().split(" ");
int bound = Math.min(words.length, 4);
List<String> list = new ArrayList<>(Arrays.asList(words).subList(0, bound));
return String.join(" ", list);
}
private PageNode buildPage(Page p) {
return PageNode.builder().height((int) p.getPageHeight()).width((int) p.getPageWidth()).number(p.getPageNumber()).mainBody(new LinkedList<>()).build();
}
private List<TextPositionSequence> mergeAndSortTextPositionSequences(List<TextBlock> textBlocks) {
Comparator<TextPositionSequence> sortByX = (sequence1, sequence2) -> (int) (sequence1.getTextPositions().get(0).getPosition()[0] - sequence2.getTextPositions()
.get(0)
.getPosition()[0]);
Comparator<TextPositionSequence> sortByY = (sequence1, sequence2) -> (int) (sequence1.getTextPositions().get(0).getPosition()[1] - sequence2.getTextPositions()
.get(0)
.getPosition()[1]);
return textBlocks.stream().map(TextBlock::getSequences).flatMap(List::stream).sorted(sortByX.thenComparing(sortByY)).toList();
}
private AtomicTextBlock buildAtomicTextBlock(List<TextPositionSequence> sequences, DocumentGraphNode parent, Context context) {
SearchTextWithTextPositionModel searchTextWithTextPositionModel = searchTextWithTextPositionFactory.buildSearchTextToTextPositionModel(sequences);
int offset = context.stringOffset().getAndAdd(searchTextWithTextPositionModel.getSearchText().length());
return AtomicTextBlock.builder()
.id(context.textBlockIdx.getAndIncrement())
.parent(parent)
.searchText(searchTextWithTextPositionModel.getSearchText())
.lineBreaks(searchTextWithTextPositionModel.getLineBreaks())
.positions(toRectangle2D(searchTextWithTextPositionModel.getPositions()))
.stringIdxToPositionIdx(searchTextWithTextPositionModel.getStringCoordsToPositionCoords())
.boundary(new Boundary(offset, offset + searchTextWithTextPositionModel.getSearchText().length()))
.build();
}
private List<Rectangle2D> toRectangle2D(List<RedRectangle2D> positions) {
return positions.stream().map(r -> (Rectangle2D) new Rectangle2D.Double(r.getX(), r.getY(), r.getWidth(), r.getHeight())).toList();
}
private PageNode getPage(int pageIndex, Context context) {
return context.pages.stream()
.filter(page -> page.getNumber() == pageIndex)
.findFirst()
.orElseThrow(() -> new NotFoundException(format("Page with number %d not found", pageIndex)));
}
record Context(
TableOfContents tableOfContents, List<PageNode> pages, List<SectionNode> sections, AtomicInteger stringOffset, AtomicLong textBlockIdx) {
}
}

View File

@ -1,12 +1,16 @@
package com.iqser.red.service.redaction.v1.server.document.services;
import static java.lang.Math.toIntExact;
import static java.lang.String.format;
import java.awt.geom.Rectangle2D;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.commons.lang3.NotImplementedException;
import org.springframework.stereotype.Service;
@ -18,13 +22,15 @@ import com.iqser.red.service.redaction.v1.server.document.data.PageData;
import com.iqser.red.service.redaction.v1.server.document.data.TableOfContentsData;
import com.iqser.red.service.redaction.v1.server.document.graph.Boundary;
import com.iqser.red.service.redaction.v1.server.document.graph.DocumentGraph;
import com.iqser.red.service.redaction.v1.server.document.graph.TableOfContents;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.DocumentGraphNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.NodeType;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.PageNode;
import com.iqser.red.service.redaction.v1.server.document.graph.TableOfContents;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.SemanticNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.NodeType;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.ParagraphNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.SectionNode;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.AtomicTextBlock;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlock;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlockCollector;
import com.iqser.red.service.redaction.v1.server.exception.NotFoundException;
@Service
@ -34,22 +40,20 @@ public class DocumentGraphMapper {
Context context = new Context(documentData, new TableOfContents(), new LinkedList<>(), new LinkedList<>(), documentData.getAtomicTextBlocks());
context.pages.addAll(documentData.getPages().stream().map(pageData -> buildPage(pageData, context)).toList());
buildNodesFromTableOfContents("", context);
DocumentGraph documentGraph= DocumentGraph.builder()
.numberOfPages(documentData.getPages().size())
.pages(context.pages)
.sections(context.sections)
.tableOfContents(context.tableOfContents)
.build();
documentGraph.setText(documentGraph.buildTextBlock());
context.pages.addAll(documentData.getPages().stream().map(this::buildPage).toList());
buildNodesFromTableOfContents(Collections.emptyList(), context);
DocumentGraph documentGraph = DocumentGraph.builder().numberOfPages(documentData.getPages().size()).pages(context.pages).tableOfContents(context.tableOfContents).build();
documentGraph.setTextBlock(documentGraph.buildTextBlock());
return documentGraph;
}
private void buildNodesFromTableOfContents(String currentTocId, Context context) {
List <TableOfContentsData.EntryData> entries;
if(currentTocId.equals("")) {
private void buildNodesFromTableOfContents(List<Integer> currentTocId, Context context) {
List<TableOfContentsData.EntryData> entries;
if (currentTocId.size() == 0) {
entries = context.documentData().getTableOfContents().getEntries();
} else {
entries = context.documentData().getTableOfContents().get(currentTocId).subEntries();
@ -65,81 +69,59 @@ public class DocumentGraphMapper {
}
private void buildSection(TableOfContentsData.EntryData entryData, String currentTocId, Context context) {
private void buildSection(TableOfContentsData.EntryData entryData, List<Integer> currentTocId, Context context) {
SectionNode section = SectionNode.builder()
.entities(new LinkedList<>())
.pages(new LinkedList<>())
.paragraphs(new LinkedList<>())
.tables(new LinkedList<>())
.subSections(new LinkedList<>())
.tableOfContents(context.tableOfContents())
.numberOnPage(entryData.numberOnPage())
.build();
SectionNode section = SectionNode.builder().entities(new HashSet<>()).pages(new HashSet<>()).tableOfContents(context.tableOfContents()).build();
context.sections().add(section);
section.setHeadline(toAtomicTextBlock(context.atomicTextBlockData().get(toIntExact(entryData.atomicTextBlock())), section));
if (!currentTocId.equals("")) {
SectionNode parent = (SectionNode) context.tableOfContents().getEntryById(currentTocId).node();
section.setParentSection(parent);
parent.getSubSections().add(section);
}
List<PageNode> pages = Arrays.stream(entryData.pages()).map(pageNumber -> getPage(pageNumber, context)).toList();
pages.forEach(page -> page.getMainBody().add(section));
section.getPages().addAll(pages);
PageNode page = getPage(entryData.page(), context);
page.getMainBody().add(section);
section.getPages().add(page);
String sectionId = context.tableOfContents.createNewEntryAndReturnId(NodeType.SECTION, buildSummary(section.getHeadline()), section);
List<Integer> sectionId = context.tableOfContents.createNewChildEntryAndReturnId(currentTocId, NodeType.SECTION, section);
section.setTocId(sectionId);
buildNodesFromTableOfContents(sectionId, context);
}
private void buildParagraph(TableOfContentsData.EntryData entryData, String currentTocId, Context context) {
private void buildParagraph(TableOfContentsData.EntryData entryData, List<Integer> currentTocId, Context context) {
Set<PageNode> pages = Arrays.stream(entryData.pages()).map(pageNumber -> getPage(pageNumber, context)).collect(Collectors.toSet());
PageNode page = getPage(entryData.page(), context);
SectionNode parentSection = (SectionNode) context.tableOfContents().getEntryById(currentTocId).node();
ParagraphNode paragraph = ParagraphNode.builder().numberOnPage(entryData.numberOnPage()).page(page).parentSection(parentSection).build();
AtomicTextBlock atomicTextBlock = toAtomicTextBlock(context.atomicTextBlockData.get(toIntExact(entryData.atomicTextBlock())), paragraph);
paragraph.setAtomicTextBlock(atomicTextBlock);
ParagraphNode paragraph = ParagraphNode.builder().pages(pages).build();
TextBlock textBlock = toTextBlock(entryData.atomicTextBlocks(), context, paragraph);
if (!page.getMainBody().contains(parentSection)) {
parentSection.getPages().add(page);
paragraph.setTerminalTextBlock(textBlock);
pages.forEach(page -> page.getMainBody().add(paragraph));
for (PageNode page : pages) {
if (!parentSection.getPages().contains(page)) {
parentSection.getPages().add(page);
}
}
page.getMainBody().add(paragraph);
String tocId = context.tableOfContents.createNewChildEntryAndReturnId(currentTocId, NodeType.PARAGRAPH, buildSummary(atomicTextBlock), paragraph);
List<Integer> tocId = context.tableOfContents.createNewChildEntryAndReturnId(currentTocId, NodeType.PARAGRAPH, paragraph);
paragraph.setTocId(tocId);
}
private PageNode buildPage(PageData p, Context context) {
private TextBlock toTextBlock(Long[] atomicTextBlockIds, Context context, SemanticNode parent) {
PageNode page = PageNode.builder().height(p.getHeight()).width(p.getWidth()).number(p.getNumber()).mainBody(new LinkedList<>()).build();
AtomicTextBlock header = toAtomicTextBlock(context.atomicTextBlockData().get(toIntExact(p.getHeader())), page);
AtomicTextBlock footer = toAtomicTextBlock(context.atomicTextBlockData().get(toIntExact(p.getFooter())), page);
page.setHeader(header);
page.setFooter(footer);
return page;
return Arrays.stream(atomicTextBlockIds)
.map(atomicTextBlockId -> toAtomicTextBlock(context.atomicTextBlockData.get(toIntExact(atomicTextBlockId)), parent))
.collect(new TextBlockCollector());
}
private static String buildSummary(com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlock textBlock) {
private PageNode buildPage(PageData p) {
if (textBlock == null) {
return " probably a table";
}
String[] words = textBlock.getFirstLine().toString().split(" ");
int bound = Math.min(words.length, 4);
List<String> list = new ArrayList<>(Arrays.asList(words).subList(0, bound));
return String.join(" ", list);
return PageNode.builder().height(p.getHeight()).width(p.getWidth()).number(p.getNumber()).mainBody(new LinkedList<>()).build();
}
private AtomicTextBlock toAtomicTextBlock(AtomicTextBlockData atomicTextBlockData, DocumentGraphNode parent) {
private AtomicTextBlock toAtomicTextBlock(AtomicTextBlockData atomicTextBlockData, SemanticNode parent) {
return AtomicTextBlock.builder()
.id(atomicTextBlockData.getId())

View File

@ -0,0 +1,90 @@
package com.iqser.red.service.redaction.v1.server.document.services;
import static java.lang.String.format;
import java.util.Set;
import org.springframework.stereotype.Service;
import com.iqser.red.service.redaction.v1.server.document.graph.Boundary;
import com.iqser.red.service.redaction.v1.server.document.graph.DocumentGraph;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.PageNode;
import com.iqser.red.service.redaction.v1.server.document.graph.TableOfContents;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.SemanticNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.EntityNode;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlock;
import com.iqser.red.service.redaction.v1.server.exception.NotFoundException;
import com.iqser.red.service.redaction.v1.server.redaction.model.EntityType;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Service
@RequiredArgsConstructor
public class EntityCreationService {
private final EntityEnrichmentService entityEnrichmentService;
public EntityNode createAndAddEntity(Boundary boundary, String type, EntityType entityType, DocumentGraph documentGraph) {
EntityNode entity = EntityNode.initialEntityNode(boundary, type, entityType);
addEntityToGraph(entity, documentGraph);
return entity;
}
public EntityNode createAndAddEntity(String entityName, int startOffset, String type, EntityType entityType, DocumentGraph documentGraph) {
int start = documentGraph.buildTextBlock().indexOf(entityName, startOffset);
if (start == -1) {
throw new NotFoundException(format("Entity %s could no be found!", entityName));
}
EntityNode entity = EntityNode.initialEntityNode(new Boundary(start, start + entityName.length()), type, entityType);
addEntityToGraph(entity, documentGraph);
return entity;
}
public void addEntityToGraph(EntityNode entity, DocumentGraph documentGraph) {
try {
SemanticNode containingNode = documentGraph.getTableOfContents()
.getEntries()
.stream()
.map(TableOfContents.Entry::node)
.filter(node -> node.buildTextBlock().containsBoundary(entity.getBoundary()))
.findFirst()
.orElseThrow(() -> new NotFoundException("No containing Node found!"));
containingNode.addThisToEntityIfIntersects(entity);
TextBlock textBlock = entity.getDeepestFullyContainingNode().buildTextBlock();
entityEnrichmentService.enrichEntity(entity, textBlock);
addToPages(entity);
addToNodeEntitySets(entity);
} catch (NotFoundException e) {
entityEnrichmentService.enrichEntity(entity, documentGraph.getTextBlock());
log.warn("Entity \"{}\" with {} is in between two main sections and will be removed!", entity.getValue(), entity.getBoundary());
entity.removeFromGraph();
}
}
private void addToPages(EntityNode entity) {
Set<PageNode> pages = entity.getDeepestFullyContainingNode().getPages();
entity.getPages().addAll(pages);
pages.forEach(page -> page.getEntities().add(entity));
}
private void addToNodeEntitySets(EntityNode entity) {
entity.getIntersectingNodes().forEach(node -> node.getEntities().add(entity));
}
}

View File

@ -0,0 +1,93 @@
package com.iqser.red.service.redaction.v1.server.document.services;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import org.springframework.stereotype.Service;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.EntityNode;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlock;
import com.iqser.red.service.redaction.v1.server.settings.RedactionServiceSettings;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class EntityEnrichmentService {
private final RedactionServiceSettings redactionServiceSettings;
public EntityNode enrichEntity(EntityNode entity, TextBlock textBlock) {
entity.setPositions(textBlock.getPositions(entity.getBoundary()));
entity.setTextAfter(findTextAfter(entity.getBoundary().end(), textBlock));
entity.setTextBefore(findTextBefore(entity.getBoundary().start(), textBlock));
entity.setValue(textBlock.subSequence(entity.getBoundary()).toString());
return entity;
}
private CharSequence findTextAfter(int index, TextBlock textBlock) {
int endOffset = Math.min(index + redactionServiceSettings.getSurroundingWordsOffsetWindow(), textBlock.getBoundary().end());
String textAfter = textBlock.subSequence(index, endOffset).toString();
if (!textAfter.isBlank()) {
List<String> wordsAfter = splitToWordsAndRemoveEmptyWords(textAfter);
int numberOfWordsAfter = Math.min(wordsAfter.size(), redactionServiceSettings.getNumberOfSurroundingWords());
if (wordsAfter.size() > 0) {
return concatWordsAfter(wordsAfter.subList(0, numberOfWordsAfter), textAfter.startsWith(" "));
}
}
return "";
}
private CharSequence findTextBefore(int index, TextBlock textBlock) {
int offsetBefore = Math.max(index - redactionServiceSettings.getSurroundingWordsOffsetWindow(), textBlock.getBoundary().start());
String textBefore = textBlock.subSequence(offsetBefore, index).toString();
if (!textBefore.isBlank()) {
List<String> wordsBefore = splitToWordsAndRemoveEmptyWords(textBefore);
int numberOfWordsBefore = Math.min(wordsBefore.size(), redactionServiceSettings.getNumberOfSurroundingWords());
if (wordsBefore.size() > 0) {
return concatWordsBefore(wordsBefore.subList(wordsBefore.size() - numberOfWordsBefore, wordsBefore.size()), textBefore.endsWith(" "));
}
}
return "";
}
private static List<String> splitToWordsAndRemoveEmptyWords(String textAfter) {
return Arrays.stream(textAfter.split(" ")).filter(word -> !Objects.equals("", word)).toList();
}
private String concatWordsBefore(List<String> words, boolean endWithSpace) {
StringBuilder sb = new StringBuilder();
for (String word : words) {
sb.append(word).append(" ");
}
String result = sb.toString().trim();
return endWithSpace ? result + " " : result;
}
private String concatWordsAfter(List<String> words, boolean startWithSpace) {
StringBuilder sb = new StringBuilder();
for (String word: words) {
sb.append(word).append(" ");
}
String result = sb.toString().trim();
return startWithSpace ? " " + result : result;
}
}

View File

@ -1,31 +0,0 @@
package com.iqser.red.service.redaction.v1.server.document.services;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.EntityNode;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlock;
public class EntityEnrichmentUtility {
public static EntityNode enrichEntity(EntityNode entity, TextBlock textBlock) {
entity.setPositions(textBlock.getPositions(entity.getBoundary()));
entity.setTextAfter(findTextAfter(entity.getBoundary().end(), textBlock));
entity.setTextBefore(findTextBefore(entity.getBoundary().start(), textBlock));
entity.setValue(textBlock.subSequence(entity.getBoundary()).toString());
return entity;
}
private static CharSequence findTextAfter(int index, TextBlock textBlock) {
int nextLineBreak = textBlock.getNextLinebreak(index);
return textBlock.subSequence(index, nextLineBreak);
}
private static CharSequence findTextBefore(int index, TextBlock textBlock) {
int previousLinebreak = textBlock.getPreviousLinebreak(index);
return textBlock.subSequence(previousLinebreak, index);
}
}

View File

@ -0,0 +1,153 @@
package com.iqser.red.service.redaction.v1.server.document.services;
import static java.util.stream.Collectors.groupingBy;
import java.awt.geom.Rectangle2D;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.stereotype.Service;
import com.iqser.red.service.redaction.v1.server.classification.model.Document;
import com.iqser.red.service.redaction.v1.server.classification.model.Section;
import com.iqser.red.service.redaction.v1.server.classification.model.TextBlock;
import com.iqser.red.service.redaction.v1.server.redaction.model.PdfImage;
import com.iqser.red.service.redaction.v1.server.tableextraction.model.AbstractTextContainer;
import com.iqser.red.service.redaction.v1.server.tableextraction.model.Cell;
import com.iqser.red.service.redaction.v1.server.tableextraction.model.Table;
@Service
public class ImageSortService {
public SortedImages sortImagesIntoStructure(Document document) {
SortedImages sortedImages = new SortedImages(new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>());
Map<Integer, List<PdfImage>> imagesByPage = document.getSections()
.stream()
.flatMap(section -> section.getImages().stream())
.distinct()
.collect(groupingBy(PdfImage::getPage));
for (int pageNumber : imagesByPage.keySet()) {
List<AbstractTextContainer> textContainersOnPage = document.getSections()
.stream()
.flatMap(section -> section.getPageBlocks().stream())
.filter(abstractTextContainer -> abstractTextContainer.getPage() == pageNumber)
.toList();
List<Section> sectionsOnPage = document.getSections()
.stream()
.filter(section -> section.getPageBlocks().stream().anyMatch(block -> block.getPage() == pageNumber))
.toList();
for (PdfImage image : imagesByPage.get(pageNumber)) {
sortImage(textContainersOnPage, sectionsOnPage, image, sortedImages);
}
}
return sortedImages;
}
private void sortImage(List<AbstractTextContainer> textContainersOnPage, List<Section> sectionsOnPage, PdfImage image, SortedImages sortedImages) {
Optional<AbstractTextContainer> containingTextContainer = getContainingTextContainer(image, textContainersOnPage);
Optional<Section> sectionContainingTextContainer = getContainingSection(image, sectionsOnPage);
List<AbstractTextContainer> containedTextContainers = getContainedTextContainers(image, textContainersOnPage);
List<Section> containedSections = getContainedSections(image, sectionsOnPage);
if (containingTextContainer.isPresent()) {
if (sortImageIntoTextContainerOrCell(image, sortedImages, containingTextContainer.get())) {
return;
}
}
if (sectionContainingTextContainer.isPresent()) {
}
}
private static boolean sortImageIntoTextContainerOrCell(PdfImage image, SortedImages sortedImages, AbstractTextContainer containingTextContainer) {
if (containingTextContainer instanceof TextBlock) {
sortedImages.containedInTextContainer().computeIfAbsent(containingTextContainer, sortedImage -> new ArrayList<>()).add(image);
return true;
}
if (containingTextContainer instanceof Table) {
Optional<Cell> containingCell = getContainingCell((Table) containingTextContainer, image);
if (containingCell.isPresent()) {
sortedImages.containedInCell().computeIfAbsent(containingCell.get(), sortedImage -> new ArrayList<>()).add(image);
return true;
}
}
return false;
}
private static Optional<Cell> getContainingCell(Table table, PdfImage image) {
return table.getRows().stream().flatMap(List::stream).filter(cell -> cell.contains(toRectangle2D(image))).findFirst();
}
private List<Section> getContainedSections(PdfImage image, List<Section> sectionsOnPage) {
return sectionsOnPage.stream()
.filter(section -> toRectangle2D(image).contains(bBoxUnion(section.getPageBlocks().stream().filter(block -> block.getPage() == image.getPage()).toList())))
.toList();
}
private List<AbstractTextContainer> getContainedTextContainers(PdfImage image, List<AbstractTextContainer> textContainersOnPage) {
return textContainersOnPage.stream().filter(textContainer -> toRectangle2D(image).contains(toRectangle2D(textContainer))).toList();
}
private Optional<Section> getContainingSection(PdfImage image, List<Section> sectionsOnPage) {
return sectionsOnPage.stream()//
.filter(section -> //
bBoxUnion(section.getPageBlocks().stream().filter(block -> block.getPage() == image.getPage()).toList())//
.contains(toRectangle2D(image))).findFirst();
}
private Optional<AbstractTextContainer> getContainingTextContainer(PdfImage image, List<AbstractTextContainer> textContainersOnPage) {
return textContainersOnPage.stream().filter(textContainer -> toRectangle2D(textContainer).contains(toRectangle2D(image))).findFirst();
}
private static Rectangle2D toRectangle2D(AbstractTextContainer container) {
return new Rectangle2D.Double(container.getMinX(), container.getMinY(), container.getWidth(), container.getHeight());
}
private static Rectangle2D toRectangle2D(PdfImage image) {
var redRectangle2D = image.getPosition();
return new Rectangle2D.Double(redRectangle2D.getX(), redRectangle2D.getY(), redRectangle2D.getWidth(), redRectangle2D.getHeight());
}
private static Rectangle2D bBoxUnion(List<AbstractTextContainer> blocks) {
return blocks.stream().map(ImageSortService::toRectangle2D).reduce((a, b) -> a.createUnion(b).getBounds2D()).orElse(new Rectangle2D.Double());
}
public record SortedImages(
Map<Cell, List<PdfImage>> containedInCell,
Map<AbstractTextContainer, List<PdfImage>> containedInTextContainer,
Map<Section, List<PdfImage>> containedInSection,
Map<PdfImage, List<AbstractTextContainer>> containedByImage,
Map<PdfImage, List<Section>> sectionContainedByImage) {
}
}

View File

@ -1,38 +0,0 @@
package com.iqser.red.service.redaction.v1.server.document.services;
import java.util.Comparator;
import com.iqser.red.service.redaction.v1.server.document.graph.Boundary;
public class RangeComparators {
public static Comparator<Boundary> contained() {
return (range1, range2) -> {
if (contained(range1, range2)) {
return -1;
} else if (contained(range2, range1)) {
return 1;
} else {
return 0;
}
};
}
/**
* @param range1 A Range
* @param range2 Also Range
* @return true, if range1 contains range2
* false, otherwise
*/
public static boolean contained(Boundary range1, Boundary range2) {
return range1.start() <= range2.start() && range2.end() <= range1.end();
}
public static boolean contained(Boundary range, int index) {
return range.start() <= index && index < range.end();
}
}

View File

@ -1,5 +1,39 @@
package com.iqser.red.service.redaction.v1.server.parsing;
import java.awt.geom.Point2D;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import org.apache.pdfbox.contentstream.operator.Operator;
import org.apache.pdfbox.contentstream.operator.OperatorName;
import org.apache.pdfbox.contentstream.operator.color.SetNonStrokingColor;
import org.apache.pdfbox.contentstream.operator.color.SetNonStrokingColorN;
import org.apache.pdfbox.contentstream.operator.color.SetNonStrokingColorSpace;
import org.apache.pdfbox.contentstream.operator.color.SetNonStrokingDeviceCMYKColor;
import org.apache.pdfbox.contentstream.operator.color.SetNonStrokingDeviceGrayColor;
import org.apache.pdfbox.contentstream.operator.color.SetNonStrokingDeviceRGBColor;
import org.apache.pdfbox.contentstream.operator.color.SetStrokingColor;
import org.apache.pdfbox.contentstream.operator.color.SetStrokingColorN;
import org.apache.pdfbox.contentstream.operator.color.SetStrokingColorSpace;
import org.apache.pdfbox.contentstream.operator.color.SetStrokingDeviceCMYKColor;
import org.apache.pdfbox.contentstream.operator.color.SetStrokingDeviceGrayColor;
import org.apache.pdfbox.contentstream.operator.color.SetStrokingDeviceRGBColor;
import org.apache.pdfbox.contentstream.operator.state.SetFlatness;
import org.apache.pdfbox.contentstream.operator.state.SetLineCapStyle;
import org.apache.pdfbox.contentstream.operator.state.SetLineDashPattern;
import org.apache.pdfbox.contentstream.operator.state.SetLineJoinStyle;
import org.apache.pdfbox.contentstream.operator.state.SetLineMiterLimit;
import org.apache.pdfbox.contentstream.operator.state.SetLineWidth;
import org.apache.pdfbox.contentstream.operator.state.SetRenderingIntent;
import org.apache.pdfbox.contentstream.operator.text.SetFontAndSize;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSNumber;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.text.TextPosition;
import com.iqser.red.service.redaction.v1.server.parsing.model.RedTextPosition;
import com.iqser.red.service.redaction.v1.server.parsing.model.TextPositionSequence;
import com.iqser.red.service.redaction.v1.server.tableextraction.model.Ruling;
@ -8,23 +42,6 @@ import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.apache.pdfbox.contentstream.operator.Operator;
import org.apache.pdfbox.contentstream.operator.OperatorName;
import org.apache.pdfbox.contentstream.operator.color.*;
import org.apache.pdfbox.contentstream.operator.state.*;
import org.apache.pdfbox.contentstream.operator.text.SetFontAndSize;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSNumber;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.text.TextPosition;
import java.awt.geom.Point2D;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
@Slf4j
public class PDFLinesTextStripper extends PDFTextStripper {
@ -264,8 +281,8 @@ public class PDFLinesTextStripper extends PDFTextStripper {
// Remove false sequence ends (whitespaces)
if (previous != null && sublist.get(0).getYDirAdj() == previous.getYDirAdj() && sublist.get(0)
.getXDirAdj() - (previous.getXDirAdj() + previous.getWidthDirAdj()) < 0.01) {
for (TextPosition t : sublist) {
textPositionSequences.get(textPositionSequences.size() - 1).add(t);
for (TextPosition textPosition : sublist) {
textPositionSequences.get(textPositionSequences.size() - 1).add(textPosition);
}
} else {
textPositionSequences.add(new TextPositionSequence(sublist, pageNumber));

View File

@ -1,5 +1,7 @@
package com.iqser.red.service.redaction.v1.server.redaction.model;
import com.iqser.red.service.redaction.v1.server.document.graph.factory.SearchTextWithTextPositionModel;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;

View File

@ -12,7 +12,7 @@ public class PdfImage {
private RedRectangle2D position;
@NonNull
private ImageType imageType;
private boolean isAppendedToParagraph;
private boolean isAppendedToSection;
@NonNull
private boolean hasTransparency;
@NonNull

View File

@ -1,90 +0,0 @@
package com.iqser.red.service.redaction.v1.server.redaction.service;
import java.util.LinkedList;
import java.util.List;
import java.util.Objects;
import org.springframework.stereotype.Service;
import com.iqser.red.service.redaction.v1.server.parsing.model.RedTextPosition;
import com.iqser.red.service.redaction.v1.server.parsing.model.TextPositionSequence;
import com.iqser.red.service.redaction.v1.server.redaction.model.RedRectangle2D;
import com.iqser.red.service.redaction.v1.server.redaction.model.SearchTextWithTextPositionModel;
@Service
public class SearchTextWithTextPositionFactory {
public SearchTextWithTextPositionModel buildSearchTextToTextPositionModel(List<TextPositionSequence> sequences) {
List<Integer> stringIdxToPositionIdx = new LinkedList<>();
List<Integer> lineBreaksStringIdx = new LinkedList<>();
StringBuilder sb = new StringBuilder();
int stringIdx = 0;
int positionIdx = 0;
String currentUnicode;
String previousUnicode = " ";
for (TextPositionSequence word : sequences) {
for (int i = 0; i < word.getTextPositions().size(); ++i) {
currentUnicode = word.getTextPositions().get(i).getUnicode();
if (isLineBreak(currentUnicode)) {
lineBreaksStringIdx.add(stringIdx + 1);
} else if (!isRepeatedWhitespace(currentUnicode, previousUnicode) && //
!isHyphenLinebreak(currentUnicode)) {
sb.append(currentUnicode);
stringIdxToPositionIdx.add(positionIdx);
++stringIdx;
}
previousUnicode = currentUnicode;
++positionIdx;
}
previousUnicode = " ";
sb.append(previousUnicode);
stringIdxToPositionIdx.add(positionIdx);
++stringIdx;
}
assert sb.length() == stringIdxToPositionIdx.size();
List<RedRectangle2D> positions = sequences.stream()//
.map(TextPositionSequence::getTextPositions)//
.flatMap(List::stream)//
.map(RedTextPosition::getPosition)//
.map(a -> (RedRectangle2D) new RedRectangle2D(a[0], a[1], a[2], a[3]))
.toList();
return SearchTextWithTextPositionModel.builder()
.searchText(sb.toString())
.lineBreaks(lineBreaksStringIdx)
.stringCoordsToPositionCoords(stringIdxToPositionIdx)
.positions(positions)
.build();
}
private boolean isLineBreak(String currentUnicode) {
return Objects.equals(currentUnicode, "\n");
}
private boolean isRepeatedWhitespace(String currentUnicode, String previousUnicode) {
return Objects.equals(previousUnicode, " ") && Objects.equals(currentUnicode, " ");
}
private boolean isHyphenLinebreak(String unicodeCharacter) {
return unicodeCharacter.matches("([^\\s\\d\\-]{2,500})[\\-\\u00AD]\\R");
}
}

View File

@ -1,20 +1,16 @@
package com.iqser.red.service.redaction.v1.server.redaction.service;
import java.util.List;
import java.util.Set;
import com.iqser.red.service.redaction.v1.server.redaction.model.Dictionary;
import com.iqser.red.service.redaction.v1.server.redaction.model.Entity;
import com.iqser.red.service.redaction.v1.server.redaction.model.SearchableText;
import com.iqser.red.service.redaction.v1.server.settings.RedactionServiceSettings;
import io.micrometer.core.annotation.Timed;
import lombok.RequiredArgsConstructor;
import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Set;
@Slf4j
@UtilityClass
public class SurroundingWordsService {

View File

@ -91,16 +91,16 @@ public class SearchImplementation {
}
public List<Boundary> getMatches(CharSequence text, int startOffset) {
public List<Boundary> getMatches(CharSequence text, Boundary region) {
if (this.values.isEmpty()) {
return new ArrayList<>();
}
CharSequence subSequence = text.subSequence(startOffset, startOffset + text.length());
CharSequence subSequence = text.subSequence(region.start(), region.end());
if (this.pattern != null) {
return this.pattern.matcher(subSequence).results().map(r -> new Boundary(r.start() + startOffset, r.end() + startOffset)).collect(Collectors.toList());
return this.pattern.matcher(subSequence).results().map(r -> new Boundary(r.start() + region.start(), r.end() + region.start())).collect(Collectors.toList());
} else {
return this.trie.parseText(subSequence).stream().map(r -> new Boundary(r.getStart() + startOffset, r.getEnd() + startOffset + 1)).collect(Collectors.toList());
return this.trie.parseText(subSequence).stream().map(r -> new Boundary(r.getStart() + region.start(), r.getEnd() + region.start() + 1)).collect(Collectors.toList());
}
}

View File

@ -3,7 +3,6 @@ package com.iqser.red.service.redaction.v1.server.segmentation;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@ -80,8 +79,7 @@ public class SectionsBuilderService {
previousTable = chunkBlock.getTables().get(chunkBlock.getTables().size() - 1);
}
}
if (current instanceof Table) {
Table table = (Table) current;
if (current instanceof Table table) {
// Distribute header information for subsequent tables
mergeTableMetadata(table, previousTable);
previousTable = table;
@ -114,46 +112,49 @@ public class SectionsBuilderService {
public void addImagesToSections(Document document) {
Map<Integer, List<Section>> paragraphMap = new HashMap<>();
for (Section paragraph : document.getSections()) {
for (AbstractTextContainer container : paragraph.getPageBlocks()) {
paragraphMap.computeIfAbsent(container.getPage(), c -> new ArrayList<>()).add(paragraph);
Map<Integer, List<Section>> sectionMap = new HashMap<>();
for (Section section : document.getSections()) {
for (AbstractTextContainer container : section.getPageBlocks()) {
List<Section> sectionsOnPage = sectionMap.computeIfAbsent(container.getPage(), c -> new ArrayList<>());
if (sectionsOnPage.contains(section)) {
continue;
}
sectionsOnPage.add(section);
}
}
if (paragraphMap.isEmpty()) {
Section paragraph = new Section();
document.getSections().add(paragraph);
paragraphMap.computeIfAbsent(1, x -> new ArrayList<>()).add(paragraph);
if (sectionMap.isEmpty()) {
Section section = new Section();
document.getSections().add(section);
sectionMap.computeIfAbsent(1, x -> new ArrayList<>()).add(section);
}
// first page is always a paragraph, else we can't process pages 1..N,
// where N is the first found page with a paragraph
if (paragraphMap.get(1) == null) {
Section paragraph = new Section();
document.getSections().add(paragraph);
paragraphMap.computeIfAbsent(1, x -> new ArrayList<>()).add(paragraph);
if (sectionMap.get(1) == null) {
Section section = new Section();
document.getSections().add(section);
sectionMap.computeIfAbsent(1, x -> new ArrayList<>()).add(section);
}
for (Page page : document.getPages()) {
for (PdfImage image : page.getImages()) {
List<Section> paragraphsOnPage = paragraphMap.get(page.getPageNumber());
if (paragraphsOnPage == null) {
List<Section> sectionsOnPage = sectionMap.get(page.getPageNumber());
if (sectionsOnPage == null) {
int i = page.getPageNumber();
while (paragraphsOnPage == null) {
paragraphsOnPage = paragraphMap.get(i);
while (sectionsOnPage == null) {
sectionsOnPage = sectionMap.get(i);
i--;
}
}
for (Section paragraph : paragraphsOnPage) {
for (Section section : sectionsOnPage) {
Float xMin = null;
Float yMin = null;
Float xMax = null;
Float yMax = null;
for (AbstractTextContainer abs : paragraph.getPageBlocks()) {
for (AbstractTextContainer abs : section.getPageBlocks()) {
if (abs.getPage() != page.getPageNumber()) {
continue;
}
@ -197,14 +198,14 @@ public class SectionsBuilderService {
if (xMin != null && xMax != null && yMin != null && yMax != null && image.getPosition().getX() >= xMin && image.getPosition()
.getX() <= xMax && image.getPosition().getY() >= yMin && image.getPosition().getY() <= yMax) {
paragraph.getImages().add(image);
image.setAppendedToParagraph(true);
section.getImages().add(image);
image.setAppendedToSection(true);
}
}
if (!image.isAppendedToParagraph()) {
if (!image.isAppendedToSection()) {
log.debug("Image uses first paragraph");
paragraphsOnPage.get(0).getImages().add(image);
image.setAppendedToParagraph(true);
sectionsOnPage.get(0).getImages().add(image);
image.setAppendedToSection(true);
}
}
}
@ -242,35 +243,16 @@ public class SectionsBuilderService {
private Section buildTextBlock(List<AbstractTextContainer> wordBlockList, String lastHeadline) {
Section section = new Section();
TextBlock textBlock = null;
int pageBefore = -1;
boolean splitByTable = false;
for (AbstractTextContainer container : wordBlockList) {
if (container instanceof Table table) {
Iterator<AbstractTextContainer> itty = wordBlockList.iterator();
boolean alreadyAdded = false;
AbstractTextContainer previous = null;
while (itty.hasNext()) {
AbstractTextContainer container = itty.next();
if (container instanceof Table) {
Table table = (Table) container;
splitByTable = true;
if (previous != null && previous.getText().startsWith("Table ")) {
table.setHeadline(previous.getText());
if (lastHeadline == null || lastHeadline.isEmpty()) {
table.setHeadline("Text in table");
} else {
if (lastHeadline == null || lastHeadline.isEmpty()) {
table.setHeadline("Text in table");
} else {
table.setHeadline("Table in: " + lastHeadline);
}
table.setHeadline("Table in: " + lastHeadline);
}
if (textBlock != null && !alreadyAdded) {
section.getPageBlocks().add(textBlock);
alreadyAdded = true;
}
section.getPageBlocks().add(table);
continue;
}

View File

@ -66,4 +66,10 @@ public abstract class AbstractTextContainer {
return maxX - minX;
}
public boolean intersectsY(AbstractTextContainer atc) {
return this.minY <= atc.getMaxY() && this.maxY >= atc.getMinY();
}
}

View File

@ -453,22 +453,23 @@ public class AbstractTestWithDictionaries {
@SneakyThrows
protected AnalyzeRequest prepareStorage(String file) {
return prepareStorage(file, "files/cv_service_empty_response.json");
return prepareStorage(file, "files/cv_service_empty_response.json", "files/empty_image_response.json");
}
@SneakyThrows
protected AnalyzeRequest prepareStorage(String file, String cvServiceResponseFile) {
protected AnalyzeRequest prepareStorage(String file, String cvServiceResponseFile, String imageInfoFile) {
ClassPathResource pdfFileResource = new ClassPathResource(file);
ClassPathResource cvServiceResponseFileResource = new ClassPathResource(cvServiceResponseFile);
ClassPathResource imageInfoFileResource = new ClassPathResource(imageInfoFile);
return prepareStorage(pdfFileResource.getInputStream(), cvServiceResponseFileResource.getInputStream());
return prepareStorage(pdfFileResource.getInputStream(), cvServiceResponseFileResource.getInputStream(), imageInfoFileResource.getInputStream());
}
@SneakyThrows
protected AnalyzeRequest prepareStorage(InputStream fileStream, InputStream cvServiceResponseFileStream) {
protected AnalyzeRequest prepareStorage(InputStream fileStream, InputStream cvServiceResponseFileStream, InputStream imageInfoStream) {
AnalyzeRequest request = AnalyzeRequest.builder()
.dossierTemplateId(TEST_DOSSIER_TEMPLATE_ID)
@ -477,6 +478,7 @@ public class AbstractTestWithDictionaries {
.lastProcessed(OffsetDateTime.now())
.build();
storageService.storeObject(RedactionStorageService.StorageIdUtils.getStorageId(TEST_DOSSIER_ID, TEST_FILE_ID, FileType.IMAGE_INFO), imageInfoStream);
storageService.storeObject(RedactionStorageService.StorageIdUtils.getStorageId(TEST_DOSSIER_ID, TEST_FILE_ID, FileType.TABLES), cvServiceResponseFileStream);
storageService.storeObject(RedactionStorageService.StorageIdUtils.getStorageId(TEST_DOSSIER_ID, TEST_FILE_ID, FileType.ORIGIN), fileStream);

View File

@ -3,15 +3,11 @@ package com.iqser.red.service.redaction.v1.server;
import static com.iqser.red.service.redaction.v1.server.utils.PdfDraw.drawRectangle2DList;
import java.awt.Color;
import java.awt.Shape;
import java.awt.geom.AffineTransform;
import java.awt.geom.Rectangle2D;
import java.io.File;
import java.io.IOException;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.junit.jupiter.api.Test;
@ -22,20 +18,20 @@ import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.io.ClassPathResource;
import com.iqser.red.service.redaction.v1.model.FileAttribute;
import com.iqser.red.service.redaction.v1.server.document.data.DocumentData;
import com.iqser.red.service.redaction.v1.server.document.graph.DocumentGraph;
import com.iqser.red.service.redaction.v1.server.document.graph.factory.DocumentGraphFactory;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.EntityNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.PageNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.ParagraphNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.SectionNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.SemanticNode;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlock;
import com.iqser.red.service.redaction.v1.server.document.services.DocumentDataMapper;
import com.iqser.red.service.redaction.v1.server.document.services.DocumentGraphFactory;
import com.iqser.red.service.redaction.v1.server.document.services.EntityCreationService;
import com.iqser.red.service.redaction.v1.server.redaction.model.Dictionary;
import com.iqser.red.service.redaction.v1.server.redaction.model.EntityType;
import com.iqser.red.service.redaction.v1.server.redaction.service.DictionaryService;
import com.iqser.red.service.redaction.v1.server.redaction.utils.SearchImplementation;
import com.iqser.red.service.redaction.v1.server.segmentation.PdfSegmentationService;
import com.iqser.red.service.redaction.v1.server.utils.PdfDraw;
import lombok.SneakyThrows;
@ -51,7 +47,7 @@ public class DocumentGraphIntegrationTest extends AbstractTestWithDictionaries {
private DictionaryService dictionaryService;
@Autowired
private DocumentDataMapper documentDataMapper;
private EntityCreationService entityCreationService;
@Qualifier("kieContainer")
@Autowired
@ -77,18 +73,21 @@ public class DocumentGraphIntegrationTest extends AbstractTestWithDictionaries {
List<EntityNode> foundEntities = new LinkedList<>();
for (var model : dictionary.getDictionaryModels()) {
findEntitiesWithSearchImplementationAndAddToGraph(document, model.getEntriesSearch(), EntityType.ENTITY, foundEntities, model.getType());
findEntitiesWithSearchImplementationAndAddToGraph(document, model.getFalsePositiveSearch(), EntityType.FALSE_POSITIVE, foundEntities, model.getType());
findEntitiesWithSearchImplementationAndAddToGraph(document, model.getFalseRecommendationsSearch(), EntityType.FALSE_RECOMMENDATION, foundEntities, model.getType());
findEntitiesWithSearchImplementation(document, model.getEntriesSearch(), EntityType.ENTITY, foundEntities, model.getType());
findEntitiesWithSearchImplementation(document, model.getFalsePositiveSearch(), EntityType.FALSE_POSITIVE, foundEntities, model.getType());
findEntitiesWithSearchImplementation(document, model.getFalseRecommendationsSearch(), EntityType.FALSE_RECOMMENDATION, foundEntities, model.getType());
foundEntities.forEach(entity -> entityCreationService.addEntityToGraph(entity, document));
}
KieSession kieSession = kieContainer.newKieSession();
kieSession.setGlobal("document", document);
document.getEntities().forEach(kieSession::insert);
document.getSections().forEach(kieSession::insert);
document.getTableOfContents().streamEntriesInOrder().forEach(entry -> kieSession.insert(entry.node()));
document.getPages().forEach(kieSession::insert);
document.getSections().forEach(sec -> sec.getParagraphs().forEach(kieSession::insert));
kieSession.insert(FileAttribute.builder().label("Vertebrate Study").value("Yes").build());
kieSession.fireAllRules();
drawAllEntities(filename, fileResource, document);
@ -109,12 +108,12 @@ public class DocumentGraphIntegrationTest extends AbstractTestWithDictionaries {
DocumentGraph document = documentGraphFactory.buildDocumentGraph(classifiedDoc);
var start = System.currentTimeMillis();
for (int i = 0; i < n; i++) {
TextBlock textBlock = document.getText();
TextBlock textBlock = document.getTextBlock();
}
float durationMillis = ((float) (System.currentTimeMillis() - start));
System.out.printf("%d calls of buildTextBlock() on document took %f s, average is %f ms\n", n, durationMillis / 1000, durationMillis / n);
SectionNode section = document.getSections().get(8);
SectionNode section = document.getMainSections().get(8);
start = System.currentTimeMillis();
for (int i = 0; i < n; i++) {
section.buildTextBlock();
@ -122,7 +121,7 @@ public class DocumentGraphIntegrationTest extends AbstractTestWithDictionaries {
durationMillis = ((float) (System.currentTimeMillis() - start));
System.out.printf("%d calls of buildTextBlock() on section took %f s, average is %f ms\n", n, durationMillis / 1000, durationMillis / n);
ParagraphNode paragraph = document.getSections().get(8).getParagraphs().get(1);
SemanticNode paragraph = document.getTableOfContents().getEntryById(List.of(8, 1)).node();
start = System.currentTimeMillis();
for (int i = 0; i < n; i++) {
paragraph.buildTextBlock();
@ -147,34 +146,42 @@ public class DocumentGraphIntegrationTest extends AbstractTestWithDictionaries {
dictionaryService.updateDictionary(TEST_DOSSIER_TEMPLATE_ID, TEST_DOSSIER_ID);
Dictionary dictionary = dictionaryService.getDeepCopyDictionary(TEST_DOSSIER_TEMPLATE_ID, TEST_DOSSIER_ID);
int numberOfSearches = 25;
float totalTime = 0;
int numberOfRuns = 25;
float totalSearchTime = 0;
float totalGraphTime = 0;
float totalInsertTime = 0;
List<EntityNode> foundEntities = new LinkedList<>();
for (int i = 0; i < numberOfSearches; i++) {
for (int i = 0; i < numberOfRuns; i++) {
foundEntities = new LinkedList<>();
var graphStart = System.currentTimeMillis();
document = documentGraphFactory.buildDocumentGraph(classifiedDoc);
var graphTime = ((float) (System.currentTimeMillis() - graphStart)) / 1000;
var graphTime = System.currentTimeMillis() - graphStart;
totalGraphTime += graphTime;
var start = System.currentTimeMillis();
foundEntities = new LinkedList<>();
var searchStart = System.currentTimeMillis();
for (var model : dictionary.getDictionaryModels()) {
findEntitiesWithSearchImplementationAndAddToGraph(document, model.getEntriesSearch(), EntityType.ENTITY, foundEntities, model.getType());
findEntitiesWithSearchImplementationAndAddToGraph(document, model.getFalsePositiveSearch(), EntityType.FALSE_POSITIVE, foundEntities, model.getType());
findEntitiesWithSearchImplementationAndAddToGraph(document, model.getFalseRecommendationsSearch(), EntityType.FALSE_RECOMMENDATION, foundEntities, model.getType());
findEntitiesWithSearchImplementation(document, model.getEntriesSearch(), EntityType.ENTITY, foundEntities, model.getType());
findEntitiesWithSearchImplementation(document, model.getFalsePositiveSearch(), EntityType.FALSE_POSITIVE, foundEntities, model.getType());
findEntitiesWithSearchImplementation(document, model.getFalseRecommendationsSearch(), EntityType.FALSE_RECOMMENDATION, foundEntities, model.getType());
}
var time = ((float) (System.currentTimeMillis() - start)) / 1000;
totalTime += time;
System.out.printf("%d Search %fs; Graph construction %fs \n", i, time, graphTime);
var searchTime = System.currentTimeMillis() - searchStart;
totalSearchTime += searchTime;
var insertStart = System.currentTimeMillis();
DocumentGraph finalDocument = document;
foundEntities.forEach(entity -> entityCreationService.addEntityToGraph(entity, finalDocument));
var insertTime = System.currentTimeMillis() - insertStart;
totalInsertTime += insertTime;
System.out.printf("%d Graph construction %d ms, Search %d ms, Insertion %d ms for %d entities\n", i, graphTime, searchTime, insertTime, document.getEntities().size());
}
System.out.printf("%d Searches took %f s, average %f s\n", numberOfSearches, totalTime, totalTime / numberOfSearches);
System.out.printf("%d Graph constructions took %f s, average %f s\n", numberOfSearches, totalGraphTime, totalGraphTime / numberOfSearches);
Set<EntityNode> distinctFoundEntities = new HashSet<>(foundEntities);
System.out.printf("Found %d entities and saved %d\n", distinctFoundEntities.size(), document.getEntities().size());
//assert document.getEntities().size() == distinctFoundEntities.size();
System.out.printf("%d Graph constructions took %s s, average %.2f ms\n", numberOfRuns, ((float) totalGraphTime / 1000), totalGraphTime / numberOfRuns);
System.out.printf("%d Searches took %s s, average %.2f ms\n", numberOfRuns, ((float) totalSearchTime / 1000), totalSearchTime / numberOfRuns);
System.out.printf("%d Insertions took %s s, average %.2f ms\n", numberOfRuns, ((float) totalInsertTime / 1000), totalInsertTime / numberOfRuns);
System.out.printf("Found %d entities and saved %d\n", foundEntities.size(), document.getEntities().size());
assert document.getEntities().size() == foundEntities.size();
drawAllEntities(filename, fileResource, document);
}
@ -187,31 +194,27 @@ public class DocumentGraphIntegrationTest extends AbstractTestWithDictionaries {
PDDocument pdDocument = PDDocument.load(fileStream);
for (PageNode page : document.getPages()) {
AffineTransform mirrorY = new AffineTransform(1, 0, 0, -1, 0, page.getHeight() + 6);
List<Rectangle2D> entityPositionsOnPage = page.getEntities()
.stream()
.filter(EntityNode::isRedact)
.filter(EntityNode::isRedaction)
.map(EntityNode::getPositions)
.flatMap(List::stream)
.map(mirrorY::createTransformedShape)
.map(Shape::getBounds2D)
.toList();
drawRectangle2DList(pdDocument, page.getNumber(), entityPositionsOnPage, Color.BLACK);
PdfDraw.Options options = PdfDraw.Options.builder().strokeColor(Color.BLACK).stroke(true).build();
drawRectangle2DList(pdDocument, page.getNumber(), entityPositionsOnPage, options);
}
for (PageNode page : document.getPages()) {
AffineTransform mirrorY = new AffineTransform(1, 0, 0, -1, 0, page.getHeight() + 6);
List<Rectangle2D> entityPositionsOnPage = page.getEntities()
.stream()
.filter(e -> !e.isRedact())
.filter(e -> !e.isRedaction())
.map(EntityNode::getPositions)
.flatMap(List::stream)
.map(mirrorY::createTransformedShape)
.map(Shape::getBounds2D)
.toList();
drawRectangle2DList(pdDocument, page.getNumber(), entityPositionsOnPage, Color.BLUE);
PdfDraw.Options options = PdfDraw.Options.builder().strokeColor(Color.BLUE).stroke(true).build();
drawRectangle2DList(pdDocument, page.getNumber(), entityPositionsOnPage, options);
}
File outputFile = new File(tmpFileName);
pdDocument.save(outputFile);
@ -220,39 +223,16 @@ public class DocumentGraphIntegrationTest extends AbstractTestWithDictionaries {
}
private void findEntitiesWithSearchImplementationAndAddToGraph(DocumentGraph documentGraph,
SearchImplementation searchImplementation,
EntityType entityType,
List<EntityNode> foundEntities,
String type) {
private void findEntitiesWithSearchImplementation(DocumentGraph documentGraph,
SearchImplementation searchImplementation,
EntityType entityType,
List<EntityNode> foundEntities,
String type) {
documentGraph.getSections().forEach( section -> {
TextBlock textBlock = section.buildTextBlock();
searchImplementation.getMatches(textBlock, textBlock.getBoundary().start())
TextBlock textBlock = documentGraph.getTextBlock();
searchImplementation.getMatches(textBlock, textBlock.getBoundary())
.stream()
.map(bounds -> section.createAndAddEntity(bounds, type, entityType))
.map(bounds -> EntityNode.initialEntityNode(bounds, type, entityType))
.forEach(foundEntities::add);
});
documentGraph.getPages().forEach( page -> {
TextBlock textBlock = page.getHeader();
searchImplementation.getMatches(textBlock, textBlock.getBoundary().start())
.stream()
.map(bounds -> page.createAndAddEntity(bounds, type, entityType))
.forEach(foundEntities::add);
});
documentGraph.getPages().forEach( page -> {
TextBlock textBlock = page.getFooter();
searchImplementation.getMatches(textBlock, textBlock.getBoundary().start())
.stream()
.map(bounds -> page.createAndAddEntity(bounds, type, entityType))
.forEach(foundEntities::add);
});
/*
searchImplementation.getMatches(documentGraph.getText())
.stream()
.map(pos -> documentGraph.createAndAddEntity(new Boundary(pos.startIndex(), pos.endIndex()), type, entityType))
.forEach(foundEntities::add);
*/
}
}

View File

@ -1,13 +1,13 @@
package com.iqser.red.service.redaction.v1.server;
package com.iqser.red.service.redaction.v1.server.document.graph;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import com.iqser.red.service.redaction.v1.server.AbstractTestWithDictionaries;
import com.iqser.red.service.redaction.v1.server.document.data.DocumentData;
import com.iqser.red.service.redaction.v1.server.document.graph.DocumentGraph;
import com.iqser.red.service.redaction.v1.server.document.services.DocumentDataMapper;
import com.iqser.red.service.redaction.v1.server.document.services.DocumentGraphFactory;
import com.iqser.red.service.redaction.v1.server.document.graph.factory.DocumentGraphFactory;
import com.iqser.red.service.redaction.v1.server.document.services.DocumentGraphMapper;
import com.iqser.red.service.redaction.v1.server.segmentation.PdfSegmentationService;

View File

@ -0,0 +1,308 @@
package com.iqser.red.service.redaction.v1.server.document.graph;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.wildfly.common.Assert.assertTrue;
import java.io.InputStream;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import com.iqser.red.service.redaction.v1.server.AbstractTestWithDictionaries;
import com.iqser.red.service.redaction.v1.server.classification.model.Document;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.PageNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.SemanticNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.EntityNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.HeadlineNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.NodeType;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.ParagraphNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.TableCellNode;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.TableNode;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlock;
import com.iqser.red.service.redaction.v1.server.document.graph.factory.DocumentGraphFactory;
import com.iqser.red.service.redaction.v1.server.document.services.EntityCreationService;
import com.iqser.red.service.redaction.v1.server.redaction.model.EntityType;
import com.iqser.red.service.redaction.v1.server.redaction.model.PdfImage;
import com.iqser.red.service.redaction.v1.server.segmentation.ImageService;
import com.iqser.red.service.redaction.v1.server.segmentation.PdfSegmentationService;
import lombok.SneakyThrows;
public class DocumentGraphTest extends AbstractTestWithDictionaries {
@Autowired
private DocumentGraphFactory documentGraphFactory;
@Autowired
private PdfSegmentationService segmentationService;
@Autowired
private EntityCreationService entityCreationService;
@Autowired
private ImageService imageService;
@Test
public void assertTextBeforeAndTextAfterForParagraphCrafted() {
DocumentGraph documentGraph = buildGraph("files/new/crafted document");
String searchTerm = "Clarissa";
int start = documentGraph.getTextBlock().indexOf(searchTerm);
assert start != -1;
Boundary boundary = new Boundary(start, start + searchTerm.length());
EntityNode entityNode = entityCreationService.createAndAddEntity(boundary, "CBI_author", EntityType.ENTITY, documentGraph);
assertEquals("Expand to Hint ", entityNode.getTextBefore());
assertEquals("s Donut ←", entityNode.getTextAfter());
assertEquals(searchTerm, entityNode.getValue());
assertEquals("Rule 5: Do not redact genitive CBI_authors (Entries based on Dict) ",
entityNode.getDeepestFullyContainingNode().getHeadline().buildTextBlock().getSearchText());
assertEquals(2, entityNode.getIntersectingNodes().size());
assertEquals(5, entityNode.getDeepestFullyContainingNode().getNumberOnPage());
assertInstanceOf(ParagraphNode.class, entityNode.getDeepestFullyContainingNode());
assertSameOffsetInAllIntersectingNodes(searchTerm, start, entityNode);
}
@Test
public void assertTextBeforeAndTextAfterForHeadlineCrafted() {
DocumentGraph documentGraph = buildGraph("files/new/crafted document");
String searchTerm = "Rule 39:";
int start = documentGraph.getTextBlock().indexOf(searchTerm);
assert start != -1;
Boundary boundary = new Boundary(start, start + searchTerm.length());
EntityNode entityNode = entityCreationService.createAndAddEntity(boundary, "CBI_author", EntityType.ENTITY, documentGraph);
assertEquals("", entityNode.getTextBefore());
assertEquals(" Purity Hint", entityNode.getTextAfter());
assertEquals(searchTerm, entityNode.getValue());
assertEquals("Rule 39: Purity Hint ", entityNode.getDeepestFullyContainingNode().getHeadline().buildTextBlock().getSearchText());
assertEquals(2, entityNode.getIntersectingNodes().size());
assertEquals(6, entityNode.getDeepestFullyContainingNode().getNumberOnPage());
assertInstanceOf(HeadlineNode.class, entityNode.getDeepestFullyContainingNode());
assertSameOffsetInAllIntersectingNodes(searchTerm, start, entityNode);
}
@Test
public void assertTextBeforeAndTextAfterForTableCellCrafted() {
DocumentGraph documentGraph = buildGraph("files/new/crafted document");
String searchTerm = "1998";
int start = documentGraph.getTextBlock().indexOf(searchTerm);
assert start != -1;
Boundary boundary = new Boundary(start, start + searchTerm.length());
EntityNode entityNode = entityCreationService.createAndAddEntity(boundary, "CBI_author", EntityType.ENTITY, documentGraph);
assertEquals("", entityNode.getTextBefore());
assertEquals("", entityNode.getTextAfter());
assertEquals(searchTerm, entityNode.getValue());
assertEquals("Rule 6-11 (Authors Table) ", entityNode.getDeepestFullyContainingNode().getHeadline().buildTextBlock().getSearchText());
assertEquals(3, entityNode.getIntersectingNodes().size());
assertEquals(15, entityNode.getDeepestFullyContainingNode().getNumberOnPage());
assertInstanceOf(TableCellNode.class, entityNode.getDeepestFullyContainingNode());
assertSameOffsetInAllIntersectingNodes(searchTerm, start, entityNode);
}
@Test
public void findAndCheckMultipleSearchTermsCrafted() {
DocumentGraph documentGraph = buildGraph("files/new/crafted document");
assertValueAndPageAndIntersectingNodes(documentGraph, "David", 1);
assertValueAndPageAndIntersectingNodes(documentGraph, "Weyland Industries", 2);
assertValueAndPageAndIntersectingNodes(documentGraph, "Desiree", 3);
assertValueAndPageAndIntersectingNodes(documentGraph, "kawasaki@me.com", 4);
assertValueAndPageAndIntersectingNodes(documentGraph, "Central Research Industry", 5);
}
@Test
public void assertTableStructure() {
DocumentGraph documentGraph = buildGraph("files/new/crafted document");
TableNode table = (TableNode) documentGraph.getTableOfContents()//
.streamEntriesInOrder()//
.filter(entry -> entry.type().equals(NodeType.TABLE))//
.map(TableOfContents.Entry::node)//
.findFirst().orElseThrow();
assertEquals(5, table.getNumberOfCols());
assertEquals(4, table.getNumberOfRows());
assertEquals(5, table.streamHeaders().toList().size());
CharSequence firstHeader = table.streamHeadersForCell(1, 1).map(TableCellNode::buildTextBlock).map(TextBlock::getSearchText).findFirst().orElseThrow();
assertEquals("Author(s)", firstHeader.toString().stripTrailing());
}
@Test
public void findAndCheckMultipleSearchTermsMetolachlor() {
DocumentGraph documentGraph = buildGraph("files/Metolachlor/S-Metolachlor_RAR_01_Volume_1_2018-09-06");
assertValueAndPageAndIntersectingNodes(documentGraph, "sideeffects", 4);
assertValueAndPageAndIntersectingNodes(documentGraph, "Commission Regulation", 9);
assertValueAndPageAndIntersectingNodes(documentGraph, "Pre-emergence", 15);
assertValueAndPageAndIntersectingNodes(documentGraph, "LiChrosorb CN +", 22);
assertValueAndPageAndIntersectingNodes(documentGraph, "RCC856132", 22);
assertValueAndPageAndIntersectingNodes(documentGraph, "Number of references included", 33);
}
@Test
public void assertTableStructureMetolachlor() {
DocumentGraph documentGraph = buildGraph("files/Metolachlor/S-Metolachlor_RAR_01_Volume_1_2018-09-06");
TableNode table = (TableNode) documentGraph.getTableOfContents()
.streamEntriesInOrder()
.filter(entry -> entry.node().getPages().stream().anyMatch(page -> page.getNumber() == 22))
.filter(entry -> entry.type().equals(NodeType.TABLE))
.map(TableOfContents.Entry::node)
.findFirst()
.orElseThrow();
assertEquals(5, table.getNumberOfCols());
assertEquals(14, table.getNumberOfRows());
assertEquals(10, table.streamHeaders().toList().size());
List<String> twoHeaders = table.streamHeadersForCell(2, 1).map(TableCellNode::buildTextBlock).map(TextBlock::getSearchText).toList();
assertEquals(2, twoHeaders.size());
assertEquals("Component of residue definition: S-Metolachlor", twoHeaders.get(0).stripTrailing());
assertEquals("Method type", twoHeaders.get(1).stripTrailing());
}
@Test
public void assertTextBeforeAndTextAfterForParagraphMetolachlor() {
DocumentGraph documentGraph = buildGraph("files/Metolachlor/S-Metolachlor_RAR_01_Volume_1_2018-09-06");
String searchTerm = "Cucurbit";
int start = documentGraph.getTextBlock().indexOf(searchTerm);
assert start != -1;
Boundary boundary = new Boundary(start, start + searchTerm.length());
EntityNode entityNode = entityCreationService.createAndAddEntity(boundary, "CBI_author", EntityType.ENTITY, documentGraph);
assertEquals("except Cranberry; Vegetable, ", entityNode.getTextBefore());
assertEquals(", Group 9;", entityNode.getTextAfter());
assertEquals("1.1.4 Evaluations carried out under other regulatory contexts ", entityNode.getDeepestFullyContainingNode().getHeadline().buildTextBlock().getSearchText());
assertEquals(searchTerm, entityNode.getValue());
assertEquals(2, entityNode.getIntersectingNodes().size());
assertEquals(5, entityNode.getDeepestFullyContainingNode().getNumberOnPage());
assertInstanceOf(ParagraphNode.class, entityNode.getDeepestFullyContainingNode());
assertSameOffsetInAllIntersectingNodes(searchTerm, start, entityNode);
}
@Test
public void assertTextBeforeAndTextAfterForHeadlineMetolachlor() {
DocumentGraph documentGraph = buildGraph("files/Metolachlor/S-Metolachlor_RAR_01_Volume_1_2018-09-06");
String searchTerm = "absorption, distribution, metabolism";
int start = documentGraph.getTextBlock().indexOf(searchTerm);
assert start != -1;
start = documentGraph.getTextBlock().indexOf(searchTerm, start + 1);
assert start != -1;
Boundary boundary = new Boundary(start, start + searchTerm.length());
EntityNode entityNode = entityCreationService.createAndAddEntity(boundary, "CBI_author", EntityType.ENTITY, documentGraph);
assertEquals("2.6.1 Summary of ", entityNode.getTextBefore());
assertEquals(" and excretion in", entityNode.getTextAfter());
assertEquals("2.6.1 Summary of absorption, distribution, metabolism and excretion in mammals ",
entityNode.getDeepestFullyContainingNode().getHeadline().buildTextBlock().getSearchText());
assertEquals(searchTerm, entityNode.getValue());
assertEquals(2, entityNode.getIntersectingNodes().size());
assertEquals(6, entityNode.getDeepestFullyContainingNode().getNumberOnPage());
assertInstanceOf(HeadlineNode.class, entityNode.getDeepestFullyContainingNode());
assertSameOffsetInAllIntersectingNodes(searchTerm, start, entityNode);
}
@Test
public void assertTextBeforeAndTextAfterForTableCellMetolachlor() {
DocumentGraph documentGraph = buildGraph("files/Metolachlor/S-Metolachlor_RAR_01_Volume_1_2018-09-06");
String searchTerm = "N-deacetylation product";
int start = documentGraph.getTextBlock().indexOf(searchTerm);
assert start != -1;
Boundary boundary = new Boundary(start, start + searchTerm.length());
EntityNode entityNode = entityCreationService.createAndAddEntity(boundary, "CBI_author", EntityType.ENTITY, documentGraph);
assertEquals("2-[(2-(1-hydroxy-ethyl)-6methyl-phenyl-amino]propan-1-ol (", entityNode.getTextBefore());
assertEquals(" of metabolite of", entityNode.getTextAfter());
assertEquals(searchTerm, entityNode.getValue());
assertEquals(3, entityNode.getIntersectingNodes().size());
assertEquals("2.7.2 Summary of metabolism, distribution and expression of residues in plants, poultry, lactating ruminants, pigs and fish ",
entityNode.getDeepestFullyContainingNode().getHeadline().buildTextBlock().getSearchText());
// TODO: the number below is odd, due the cell (1, 2) containing 4 AtomicTextBlocks, even though it is an image. This should be fixed during structure analysis.
// In general, numberOnPage needs some more thought.
assertEquals(25, entityNode.getDeepestFullyContainingNode().getNumberOnPage());
assertInstanceOf(TableCellNode.class, entityNode.getDeepestFullyContainingNode());
assertSameOffsetInAllIntersectingNodes(searchTerm, start, entityNode);
}
@SneakyThrows
protected DocumentGraph buildGraph(String filename) {
if (filename.equals("files/Metolachlor/S-Metolachlor_RAR_01_Volume_1_2018-09-06")) {
prepareStorage(filename + ".pdf", "files/cv_service_empty_response.json", filename + ".IMAGE_INFO.json");
} else {
prepareStorage(filename + ".pdf");
}
ClassPathResource fileResource = new ClassPathResource(filename + ".pdf");
try (InputStream inputStream = fileResource.getInputStream()) {
Map<Integer, List<PdfImage>> pdfImages = imageService.convertImages(TEST_DOSSIER_ID, TEST_FILE_ID);
Document classifiedDoc = segmentationService.parseDocument(TEST_DOSSIER_ID, TEST_FILE_ID, inputStream, pdfImages);
return documentGraphFactory.buildDocumentGraph(classifiedDoc);
}
}
private static void assertSameOffsetInAllIntersectingNodes(String searchTerm, int start, EntityNode entityNode) {
List<Integer> paragraphStart = entityNode.getIntersectingNodes().stream()//
.map(SemanticNode::buildTextBlock)//
.map(textBlock -> textBlock.indexOf(searchTerm))//
.toList();
paragraphStart.forEach(nodeStart -> assertEquals(start, nodeStart));
}
private void assertValueAndPageAndIntersectingNodes(DocumentGraph documentGraph, String searchTerm, int pageNumber) {
int start = documentGraph.getTextBlock().indexOf(searchTerm);
assert start != -1;
Boundary boundary = new Boundary(start, start + searchTerm.length());
EntityNode entityNode = entityCreationService.createAndAddEntity(boundary, "CBI_author", EntityType.ENTITY, documentGraph);
PageNode pageNode = documentGraph.getPages().get(pageNumber - 1);
assertEquals(entityNode.getValue(), searchTerm);
assertTrue(pageNode.getEntities().contains(entityNode));
assertTrue(documentGraph.getPages().stream().filter(page -> page != pageNode).noneMatch(page -> page.getEntities().contains(entityNode)));
assertTrue(entityNode.getPages().contains(pageNode));
assertTrue(entityNode.getPositions().size() >= searchTerm.replace(" ", "").length());
assertSameOffsetInAllIntersectingNodes(searchTerm, start, entityNode);
assertTrue(entityNode.getIntersectingNodes().stream().allMatch(node -> node.getEntities().contains(entityNode)));
}
}

View File

@ -0,0 +1,59 @@
package com.iqser.red.service.redaction.v1.server.document.graph;
import java.awt.Color;
import java.io.File;
import java.io.IOException;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlock;
import com.iqser.red.service.redaction.v1.server.utils.PdfDraw;
import lombok.SneakyThrows;
public class DocumentGraphVisualizationTest extends DocumentGraphTest {
@Test
@SneakyThrows
public void visualizeMetolachlor() {
String filename = "files/Metolachlor/S-Metolachlor_RAR_01_Volume_1_2018-09-06";
DocumentGraph documentGraph = buildGraph(filename);
TextBlock textBlock = documentGraph.buildTextBlock();
visualizeSemanticNodes(filename, documentGraph, textBlock);
}
@Test
@SneakyThrows
public void visualizeRotatedTestDocument() {
String filename = "files/new/RotateTestFileWithImages";
DocumentGraph documentGraph = buildGraph(filename);
TextBlock textBlock = documentGraph.buildTextBlock();
visualizeSemanticNodes(filename, documentGraph, textBlock);
}
private static void visualizeSemanticNodes(String filename, DocumentGraph documentGraph, TextBlock textBlock) throws IOException {
var tmpFileName = "/tmp/" + filename.split("/")[2] + "_SEMANTIC_NODES_BBOX.pdf";
var fileResource = new ClassPathResource(filename + ".pdf");
try (var fileStream = fileResource.getInputStream()) {
PDDocument pdDocument = PDDocument.load(fileStream);
PdfDraw.drawDocumentGraph(pdDocument, documentGraph);
PdfDraw.drawTextBlock(pdDocument, textBlock, PdfDraw.Options.builder().stroke(true).strokeWidth(0.1f).strokeColor(Color.YELLOW).build());
File outputFile = new File(tmpFileName);
pdDocument.save(outputFile);
pdDocument.close();
}
}
}

View File

@ -0,0 +1,60 @@
package com.iqser.red.service.redaction.v1.server.document.graph;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.io.InputStream;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import com.iqser.red.service.redaction.v1.server.AbstractTestWithDictionaries;
import com.iqser.red.service.redaction.v1.server.classification.model.Document;
import com.iqser.red.service.redaction.v1.server.document.services.ImageSortService;
import com.iqser.red.service.redaction.v1.server.redaction.model.PdfImage;
import com.iqser.red.service.redaction.v1.server.segmentation.ImageService;
import com.iqser.red.service.redaction.v1.server.segmentation.PdfSegmentationService;
import lombok.SneakyThrows;
public class ImageSortServiceTest extends AbstractTestWithDictionaries {
@Autowired
private ImageSortService imageSortService;
@Autowired
private PdfSegmentationService segmentationService;
@Autowired
private ImageService imageService;
@Test
public void assertImagesContainedInCells() {
ImageSortService.SortedImages sortedImages = getSortedImages("files/Metolachlor/S-Metolachlor_RAR_01_Volume_1_2018-09-06");
assertEquals(9, sortedImages.containedInCell().size());
}
@SneakyThrows
private ImageSortService.SortedImages getSortedImages(String filename) {
if (filename.equals("files/Metolachlor/S-Metolachlor_RAR_01_Volume_1_2018-09-06")) {
prepareStorage(filename + ".pdf", "files/cv_service_empty_response.json", filename + ".IMAGE_INFO.json");
} else {
prepareStorage(filename + ".pdf");
}
ClassPathResource fileResource = new ClassPathResource(filename + ".pdf");
try (InputStream inputStream = fileResource.getInputStream()) {
Map<Integer, List<PdfImage>> pdfImages = imageService.convertImages(TEST_DOSSIER_ID, TEST_FILE_ID);
Document classifiedDoc = segmentationService.parseDocument(TEST_DOSSIER_ID, TEST_FILE_ID, inputStream, pdfImages);
return imageSortService.sortImagesIntoStructure(classifiedDoc);
}
}
}

View File

@ -1,27 +1,157 @@
package com.iqser.red.service.redaction.v1.server.utils;
import java.awt.Color;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import com.iqser.red.service.redaction.v1.server.document.graph.DocumentGraph;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.PageNode;
import com.iqser.red.service.redaction.v1.server.document.graph.TableOfContents;
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.NodeType;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.AtomicTextBlock;
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlock;
import com.iqser.red.service.redaction.v1.server.document.graph.factory.RectangleTransformations;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.SneakyThrows;
import lombok.experimental.FieldDefaults;
import lombok.experimental.UtilityClass;
@UtilityClass
public class PdfDraw {
public static void drawDocumentGraph(PDDocument document, DocumentGraph documentGraph) {
documentGraph.getTableOfContents().streamEntriesInOrder().forEach(entry -> drawNode(document, entry));
}
public static void drawNode(PDDocument document, TableOfContents.Entry entry) {
Options options = buildStandardOptionsForNodes(entry);
drawBBoxAndLabelAndNumberOnPage(document, entry, options);
}
public static void drawTextBlock(PDDocument document, TextBlock textBlock, Options options) {
textBlock.getAtomicTextBlocks().forEach(atb -> drawAtomicTextBlock(document, atb, options));
}
public static void drawAtomicTextBlock(PDDocument document, AtomicTextBlock atomicTextBlock, Options options) {
drawRectangle2DList(document, atomicTextBlock.getPage().getNumber(), atomicTextBlock.getPositions().stream().toList(), options);
}
@SneakyThrows
public static void drawRectangle2DList(PDDocument document, int pageNumber, List<Rectangle2D> rectCollection, Color color) {
private static void drawText(String string, PDDocument document, Point2D location, Integer pageNumber, Options options) {
var pdPage = document.getPage(pageNumber - 1);
var contentStream = new PDPageContentStream(document, pdPage, PDPageContentStream.AppendMode.APPEND, true);
contentStream.setStrokingColor(color);
contentStream.setNonStrokingColor(options.getStrokeColor());
contentStream.setLineWidth(options.getStrokeWidth());
contentStream.beginText();
contentStream.newLineAtOffset((float) location.getX(), (float) location.getY());
contentStream.setFont(PDType1Font.HELVETICA_OBLIQUE, 10);
contentStream.showText(string);
contentStream.endText();
contentStream.close();
}
@SneakyThrows
public static void drawRectangle2DList(PDDocument document, int pageNumber, List<Rectangle2D> rectCollection, Options options) {
var pdPage = document.getPage(pageNumber - 1);
var contentStream = new PDPageContentStream(document, pdPage, PDPageContentStream.AppendMode.APPEND, true);
contentStream.setStrokingColor(options.getStrokeColor());
contentStream.setNonStrokingColor(options.getFillColor());
contentStream.setLineWidth(options.getStrokeWidth());
for (var r : rectCollection) {
contentStream.addRect((float) r.getMinX(), (float) r.getMinY(), (float) r.getWidth(), (float) r.getHeight());
contentStream.stroke();
if (options.isStroke() && options.isFill()) {
contentStream.fillAndStroke();
} else if (options.isStroke()) {
contentStream.stroke();
} else if (options.isFill()) {
contentStream.fill();
}
}
contentStream.close();
}
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
public static class Options {
@Builder.Default
boolean stroke = false;
@Builder.Default
Color strokeColor = Color.BLACK;
@Builder.Default
float strokeWidth = 1f;
@Builder.Default
boolean fill = false;
@Builder.Default
Color fillColor = Color.BLACK;
}
private static Options buildStandardOptionsForNodes(TableOfContents.Entry entry) {
return Options.builder().stroke(true).strokeColor(switch (entry.type()) {
case HEADER, FOOTER -> Color.GREEN;
case PARAGRAPH -> Color.BLUE;
case HEADLINE -> Color.RED;
case SECTION -> Color.BLACK;
case TABLE -> Color.ORANGE;
case TABLE_CELL -> Color.GRAY;
case IMAGE -> Color.MAGENTA;
}).build();
}
private static void drawBBoxAndLabelAndNumberOnPage(PDDocument document, TableOfContents.Entry entry, Options options) {
Map<PageNode, Rectangle2D> rectanglesPerPage = entry.node().getBBox();
rectanglesPerPage.forEach((page, rectangle2D) -> {
if (entry.type() == NodeType.SECTION) {
rectangle2D = RectangleTransformations.pad(rectangle2D, 10, 10);
}
drawRectangle2DList(document, page.getNumber(), List.of(rectangle2D), options);
drawText(buildString(entry), document, new Point2D.Double(rectangle2D.getMinX(), rectangle2D.getMaxY() + 2), page.getNumber(), options);
});
}
private static String buildString(TableOfContents.Entry entry) {
return entry.node().getNumberOnPage() + ": " + entry.tocId() + ": " + entry.type().toString();
}
}

View File

@ -0,0 +1,638 @@
{
"dossierId": "123",
"fileId": "123",
"targetFileExtension": "ORIGIN.pdf.gz",
"responseFileExtension": "IMAGE_INFO.json.gz",
"data": [
{
"classification": {
"label": "formula",
"probabilities": {
"formula": 1.0,
"logo": 0.0,
"other": 0.0,
"signature": 0.0
}
},
"representation": "DE7BE326BAC8CC872F3EF1FFC",
"position": {
"x1": 259,
"x2": 369,
"y1": 372,
"y2": 465,
"pageNumber": 54
},
"geometry": {
"width": 110,
"height": 93
},
"alpha": false,
"filters": {
"geometry": {
"imageSize": {
"quotient": 0.1429,
"tooLarge": false,
"tooSmall": false
},
"imageFormat": {
"quotient": 1.1828,
"tooTall": false,
"tooWide": false
}
},
"probability": {
"unconfident": false
},
"allPassed": true
}
},
{
"classification": {
"label": "formula",
"probabilities": {
"formula": 1.0,
"logo": 0.0,
"other": 0.0,
"signature": 0.0
}
},
"representation": "BDF2E766B088D8976E3EFBFFD",
"position": {
"x1": 259,
"x2": 370,
"y1": 558,
"y2": 633,
"pageNumber": 54
},
"geometry": {
"width": 111,
"height": 75
},
"alpha": false,
"filters": {
"geometry": {
"imageSize": {
"quotient": 0.1289,
"tooLarge": false,
"tooSmall": false
},
"imageFormat": {
"quotient": 1.48,
"tooTall": false,
"tooWide": false
}
},
"probability": {
"unconfident": false
},
"allPassed": true
}
},
{
"classification": {
"label": "formula",
"probabilities": {
"formula": 1.0,
"logo": 0.0,
"other": 0.0,
"signature": 0.0
}
},
"representation": "FCFBE026A8F86A802F3EFBFFE",
"position": {
"x1": 259,
"x2": 370,
"y1": 476,
"y2": 552,
"pageNumber": 54
},
"geometry": {
"width": 111,
"height": 76
},
"alpha": false,
"filters": {
"geometry": {
"imageSize": {
"quotient": 0.1298,
"tooLarge": false,
"tooSmall": false
},
"imageFormat": {
"quotient": 1.4605,
"tooTall": false,
"tooWide": false
}
},
"probability": {
"unconfident": false
},
"allPassed": true
}
},
{
"classification": {
"label": "formula",
"probabilities": {
"formula": 1.0,
"logo": 0.0,
"other": 0.0,
"signature": 0.0
}
},
"representation": "F3EF89CF8C7262A33E9EFAFFE",
"position": {
"x1": 259,
"x2": 372,
"y1": 274,
"y2": 366,
"pageNumber": 54
},
"geometry": {
"width": 113,
"height": 92
},
"alpha": false,
"filters": {
"geometry": {
"imageSize": {
"quotient": 0.1441,
"tooLarge": false,
"tooSmall": false
},
"imageFormat": {
"quotient": 1.2283,
"tooTall": false,
"tooWide": false
}
},
"probability": {
"unconfident": false
},
"allPassed": true
}
},
{
"classification": {
"label": "formula",
"probabilities": {
"formula": 1.0,
"logo": 0.0,
"other": 0.0,
"signature": 0.0
}
},
"representation": "FBFFC72232C8C8872F3EF1FFC",
"position": {
"x1": 259,
"x2": 372,
"y1": 183,
"y2": 268,
"pageNumber": 54
},
"geometry": {
"width": 113,
"height": 85
},
"alpha": false,
"filters": {
"geometry": {
"imageSize": {
"quotient": 0.1385,
"tooLarge": false,
"tooSmall": false
},
"imageFormat": {
"quotient": 1.3294,
"tooTall": false,
"tooWide": false
}
},
"probability": {
"unconfident": false
},
"allPassed": true
}
},
{
"classification": {
"label": "formula",
"probabilities": {
"formula": 1.0,
"logo": 0.0,
"other": 0.0,
"signature": 0.0
}
},
"representation": "FBFD873286106785EF78F0CF1",
"position": {
"x1": 259,
"x2": 449,
"y1": 111,
"y2": 177,
"pageNumber": 54
},
"geometry": {
"width": 190,
"height": 66
},
"alpha": false,
"filters": {
"geometry": {
"imageSize": {
"quotient": 0.1582,
"tooLarge": false,
"tooSmall": false
},
"imageFormat": {
"quotient": 2.8788,
"tooTall": false,
"tooWide": false
}
},
"probability": {
"unconfident": false
},
"allPassed": true
}
},
{
"classification": {
"label": "formula",
"probabilities": {
"formula": 1.0,
"logo": 0.0,
"other": 0.0,
"signature": 0.0
}
},
"representation": "7EF1C79E5B016ACFFFBD04442",
"position": {
"x1": 259,
"x2": 372,
"y1": 464,
"y2": 538,
"pageNumber": 55
},
"geometry": {
"width": 113,
"height": 74
},
"alpha": false,
"filters": {
"geometry": {
"imageSize": {
"quotient": 0.1292,
"tooLarge": false,
"tooSmall": false
},
"imageFormat": {
"quotient": 1.527,
"tooTall": false,
"tooWide": false
}
},
"probability": {
"unconfident": false
},
"allPassed": true
}
},
{
"classification": {
"label": "formula",
"probabilities": {
"formula": 1.0,
"logo": 0.0,
"other": 0.0,
"signature": 0.0
}
},
"representation": "F9F7ED37781D208B3FAEF0CF4",
"position": {
"x1": 259,
"x2": 440,
"y1": 628,
"y2": 721,
"pageNumber": 55
},
"geometry": {
"width": 181,
"height": 93
},
"alpha": false,
"filters": {
"geometry": {
"imageSize": {
"quotient": 0.1833,
"tooLarge": false,
"tooSmall": false
},
"imageFormat": {
"quotient": 1.9462,
"tooTall": false,
"tooWide": false
}
},
"probability": {
"unconfident": false
},
"allPassed": true
}
},
{
"classification": {
"label": "formula",
"probabilities": {
"formula": 1.0,
"logo": 0.0,
"other": 0.0,
"signature": 0.0
}
},
"representation": "FD778D164B00E389FF9EF0CF4",
"position": {
"x1": 259,
"x2": 440,
"y1": 544,
"y2": 622,
"pageNumber": 55
},
"geometry": {
"width": 181,
"height": 78
},
"alpha": false,
"filters": {
"geometry": {
"imageSize": {
"quotient": 0.1679,
"tooLarge": false,
"tooSmall": false
},
"imageFormat": {
"quotient": 2.3205,
"tooTall": false,
"tooWide": false
}
},
"probability": {
"unconfident": false
},
"allPassed": true
}
},
{
"classification": {
"label": "other",
"probabilities": {
"other": 0.9342,
"signature": 0.0474,
"logo": 0.0183,
"formula": 0.0002
}
},
"representation": "00CFFEF9F7EF9F7EF9F7FF3",
"position": {
"x1": 537,
"x2": 707,
"y1": 462,
"y2": 511,
"pageNumber": 74
},
"geometry": {
"width": 170,
"height": 49
},
"alpha": false,
"filters": {
"geometry": {
"imageSize": {
"quotient": 0.1289,
"tooLarge": false,
"tooSmall": false
},
"imageFormat": {
"quotient": 3.4694,
"tooTall": false,
"tooWide": false
}
},
"probability": {
"unconfident": false
},
"allPassed": true
}
},
{
"classification": {
"label": "other",
"probabilities": {
"other": 0.9435,
"signature": 0.0403,
"logo": 0.016,
"formula": 0.0001
}
},
"representation": "00CFFFFFFFFFFFFFFFFFFF3",
"position": {
"x1": 537,
"x2": 707,
"y1": 437,
"y2": 453,
"pageNumber": 74
},
"geometry": {
"width": 170,
"height": 16
},
"alpha": false,
"filters": {
"geometry": {
"imageSize": {
"quotient": 0.0737,
"tooLarge": false,
"tooSmall": false
},
"imageFormat": {
"quotient": 10.625,
"tooTall": false,
"tooWide": true
}
},
"probability": {
"unconfident": false
},
"allPassed": false
}
},
{
"classification": {
"label": "other",
"probabilities": {
"other": 0.9998,
"logo": 0.0001,
"formula": 0.0,
"signature": 0.0
}
},
"representation": "FFFFFFFFFFFFFFFFFFFFFFFFF",
"position": {
"x1": 442,
"x2": 499,
"y1": 308,
"y2": 351,
"pageNumber": 154
},
"geometry": {
"width": 57,
"height": 43
},
"alpha": false,
"filters": {
"geometry": {
"imageSize": {
"quotient": 0.0699,
"tooLarge": false,
"tooSmall": false
},
"imageFormat": {
"quotient": 1.3256,
"tooTall": false,
"tooWide": false
}
},
"probability": {
"unconfident": false
},
"allPassed": true
}
},
{
"classification": {
"label": "other",
"probabilities": {
"other": 1.0,
"formula": 0.0,
"logo": 0.0,
"signature": 0.0
}
},
"representation": "2C3083D04BD8CF2FFCF3F7EFF",
"position": {
"x1": 71,
"x2": 524,
"y1": 318,
"y2": 771,
"pageNumber": 165
},
"geometry": {
"width": 453,
"height": 453
},
"alpha": false,
"filters": {
"geometry": {
"imageSize": {
"quotient": 0.64,
"tooLarge": false,
"tooSmall": false
},
"imageFormat": {
"quotient": 1.0,
"tooTall": false,
"tooWide": false
}
},
"probability": {
"unconfident": false
},
"allPassed": true
}
},
{
"classification": {
"label": "other",
"probabilities": {
"other": 1.0,
"formula": 0.0,
"logo": 0.0,
"signature": 0.0
}
},
"representation": "FFF0010400FFECD31C60FFCFF",
"position": {
"x1": 93,
"x2": 502,
"y1": 374,
"y2": 495,
"pageNumber": 185
},
"geometry": {
"width": 409,
"height": 121
},
"alpha": false,
"filters": {
"geometry": {
"imageSize": {
"quotient": 0.3143,
"tooLarge": false,
"tooSmall": false
},
"imageFormat": {
"quotient": 3.3802,
"tooTall": false,
"tooWide": false
}
},
"probability": {
"unconfident": false
},
"allPassed": true
}
},
{
"classification": {
"label": "formula",
"probabilities": {
"formula": 1.0,
"logo": 0.0,
"other": 0.0,
"signature": 0.0
}
},
"representation": "33F5D7760837FFF3660877FFF",
"position": {
"x1": 158,
"x2": 437,
"y1": 538,
"y2": 771,
"pageNumber": 185
},
"geometry": {
"width": 279,
"height": 233
},
"alpha": false,
"filters": {
"geometry": {
"imageSize": {
"quotient": 0.3602,
"tooLarge": false,
"tooSmall": false
},
"imageFormat": {
"quotient": 1.1974,
"tooTall": false,
"tooWide": false
}
},
"probability": {
"unconfident": false
},
"allPassed": true
}
}
]
}

View File

@ -0,0 +1,5 @@
{
"dossierId": "f889853e-4bf8-49a9-aae5-c38605c6ef40",
"fileId": "22ef63e29bb2a27db8497272336f6b32",
"data": []
}