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:
parent
f3bfe95ea7
commit
dc76e581c4
@ -1,23 +1,16 @@
|
|||||||
package com.iqser.red.service.redaction.v1.server.classification.model;
|
package com.iqser.red.service.redaction.v1.server.classification.model;
|
||||||
|
|
||||||
import com.iqser.red.service.persistence.service.v1.api.model.annotations.AnnotationStatus;
|
import java.util.ArrayList;
|
||||||
import com.iqser.red.service.persistence.service.v1.api.model.annotations.entitymapped.IdRemoval;
|
import java.util.List;
|
||||||
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 com.iqser.red.service.redaction.v1.server.redaction.model.PdfImage;
|
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.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.AbstractTextContainer;
|
||||||
import com.iqser.red.service.redaction.v1.server.tableextraction.model.Table;
|
import com.iqser.red.service.redaction.v1.server.tableextraction.model.Table;
|
||||||
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
public class Section implements Comparable {
|
public class Section implements Comparable {
|
||||||
@ -68,10 +61,4 @@ public class Section implements Comparable {
|
|||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.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.CellValue;
|
||||||
import com.iqser.red.service.redaction.v1.server.redaction.model.Image;
|
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 com.iqser.red.service.redaction.v1.server.redaction.model.SearchableText;
|
||||||
|
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
@ -39,7 +38,6 @@ public class SectionText {
|
|||||||
|
|
||||||
private boolean isTable;
|
private boolean isTable;
|
||||||
private String headline;
|
private String headline;
|
||||||
List<Paragraph> paragraphs;
|
|
||||||
|
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private List<SectionArea> sectionAreas = new ArrayList<>();
|
private List<SectionArea> sectionAreas = new ArrayList<>();
|
||||||
|
|||||||
@ -14,7 +14,4 @@ public class PageData {
|
|||||||
int number;
|
int number;
|
||||||
int height;
|
int height;
|
||||||
int width;
|
int width;
|
||||||
|
|
||||||
Long header;
|
|
||||||
Long footer;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,14 +24,14 @@ public class TableOfContentsData {
|
|||||||
List<EntryData> entries;
|
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));
|
throw new InvalidKeyException(format("Section Identifier: \"%s\" is not valid.", tocId));
|
||||||
}
|
}
|
||||||
EntryData entry = entries.get(ids.get(0));
|
EntryData entry = entries.get(tocId.get(0));
|
||||||
for (int id : ids.subList(1, ids.size())) {
|
for (int id : tocId.subList(1, tocId.size())) {
|
||||||
entry = entry.subEntries().get(id);
|
entry = entry.subEntries().get(id);
|
||||||
}
|
}
|
||||||
return entry;
|
return entry;
|
||||||
@ -45,7 +45,7 @@ public class TableOfContentsData {
|
|||||||
|
|
||||||
|
|
||||||
@Builder
|
@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) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import static java.lang.String.format;
|
|||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
|
|
||||||
@Setter
|
@Setter
|
||||||
public class Boundary {
|
public class Boundary implements Comparable<Boundary> {
|
||||||
|
|
||||||
private int start;
|
private int start;
|
||||||
private int end;
|
private int end;
|
||||||
@ -13,7 +13,9 @@ public class Boundary {
|
|||||||
|
|
||||||
public Boundary(int start, int end) {
|
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.start = start;
|
||||||
this.end = end;
|
this.end = end;
|
||||||
}
|
}
|
||||||
@ -75,7 +77,7 @@ public class Boundary {
|
|||||||
|
|
||||||
public boolean intersects(Boundary 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);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +1,16 @@
|
|||||||
package com.iqser.red.service.redaction.v1.server.document.graph;
|
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.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import java.util.stream.Stream;
|
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.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.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.TextBlock;
|
||||||
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlockCollector;
|
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.AccessLevel;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
@ -32,56 +26,37 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
@FieldDefaults(level = AccessLevel.PRIVATE)
|
@FieldDefaults(level = AccessLevel.PRIVATE)
|
||||||
public class DocumentGraph {
|
public class DocumentGraph {
|
||||||
|
|
||||||
List<SectionNode> sections;
|
|
||||||
List<PageNode> pages;
|
List<PageNode> pages;
|
||||||
TableOfContents tableOfContents;
|
TableOfContents tableOfContents;
|
||||||
Integer numberOfPages;
|
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(//
|
return tableOfContents.entries.stream().filter(entry -> entry.node() instanceof SectionNode).map(entry -> (SectionNode) entry.node()).collect(Collectors.toList());
|
||||||
streamAllNodes().filter(DocumentGraphNode::isTerminal).map(DocumentGraphNode::getAtomicTextBlock),//
|
|
||||||
Stream.concat(//
|
|
||||||
pages.stream().map(PageNode::getHeader),//
|
|
||||||
pages.stream().map(PageNode::getFooter)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public EntityNode createAndAddEntity(Boundary boundary, String type, EntityType entityType) {
|
public Stream<TextBlock> streamTextBlocksInOrder() {
|
||||||
|
|
||||||
EntityNode entity = EntityNode.initialEntityNode(boundary, type, entityType);
|
return streamAllNodes().filter(SemanticNode::isTerminal).map(SemanticNode::getTerminalTextBlock);
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public Set<EntityNode> getEntities() {
|
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);
|
return tableOfContents.streamEntriesInOrder().map(TableOfContents.Entry::node);
|
||||||
}
|
}
|
||||||
@ -90,7 +65,7 @@ public class DocumentGraph {
|
|||||||
@Override
|
@Override
|
||||||
public String toString() {
|
public String toString() {
|
||||||
|
|
||||||
return text.toString();
|
return tableOfContents.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
@ -3,17 +3,18 @@ package com.iqser.red.service.redaction.v1.server.document.graph;
|
|||||||
import static java.lang.String.format;
|
import static java.lang.String.format;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.LinkedList;
|
import java.util.LinkedList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
import javax.management.openmbean.InvalidKeyException;
|
|
||||||
|
|
||||||
import com.google.common.hash.Hashing;
|
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 com.iqser.red.service.redaction.v1.server.document.graph.nodes.NodeType;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
@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());
|
return createNewChildEntryAndReturnId(Collections.emptyList(), nodeType, node);
|
||||||
entries.add(new Entry(nodeType, id, summary, new LinkedList<>(), node));
|
|
||||||
return id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public String createNewChildEntryAndReturnId(String parentId, NodeType nodeType, String summary, DocumentGraphNode node) {
|
public List<Integer> createNewChildEntryAndReturnId(List<Integer> parentId, NodeType nodeType, SemanticNode node) {
|
||||||
|
|
||||||
|
List<Integer> newId;
|
||||||
|
if (entryExists(parentId)) {
|
||||||
Entry parent = getEntryById(parentId);
|
Entry parent = getEntryById(parentId);
|
||||||
String childId = parentId + String.format(".%d", parent.children().size());
|
newId = new LinkedList<>(parentId);
|
||||||
parent.children().add(new Entry(nodeType, childId, summary, new LinkedList<>(), node));
|
newId.add(parent.children().size());
|
||||||
return childId;
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
return newId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public Entry getEntryById(String parentId) {
|
private boolean entryExists(List<Integer> tocId) {
|
||||||
|
|
||||||
List<Integer> ids = getIds(parentId);
|
if (tocId.size() < 1) {
|
||||||
if (ids.size() < 1) {
|
return false;
|
||||||
throw new InvalidKeyException(format("Section Identifier: \"%s\" is not valid.", parentId));
|
|
||||||
}
|
}
|
||||||
Entry entry = entries.get(ids.get(0));
|
Entry entry = entries.get(tocId.get(0));
|
||||||
for (int id : ids.subList(1, ids.size())) {
|
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);
|
entry = entry.children().get(id);
|
||||||
}
|
}
|
||||||
return entry;
|
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
|
@Override
|
||||||
public String toString() {
|
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());
|
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) {
|
private static Stream<Entry> flatten(Entry entry) {
|
||||||
|
|
||||||
return Stream.concat(Stream.of(entry), entry.children().stream().flatMap(TableOfContents::flatten));
|
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
|
@Override
|
||||||
public String toString() {
|
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
|
@Override
|
||||||
public int hashCode() {
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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 java.util.List;
|
||||||
|
|
||||||
import lombok.AccessLevel;
|
import lombok.AccessLevel;
|
||||||
@ -15,5 +16,5 @@ public class SearchTextWithTextPositionModel {
|
|||||||
String searchText;
|
String searchText;
|
||||||
List<Integer> lineBreaks;
|
List<Integer> lineBreaks;
|
||||||
List<Integer> stringCoordsToPositionCoords;
|
List<Integer> stringCoordsToPositionCoords;
|
||||||
List<RedRectangle2D> positions;
|
List<Rectangle2D> positions;
|
||||||
}
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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()));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -3,6 +3,7 @@ package com.iqser.red.service.redaction.v1.server.document.graph.nodes;
|
|||||||
import java.awt.geom.Rectangle2D;
|
import java.awt.geom.Rectangle2D;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
@ -60,42 +61,65 @@ public class EntityNode {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
String legalBasis = "";
|
String legalBasis = "";
|
||||||
|
|
||||||
// inferrable from graph
|
// inferred on graph insertion
|
||||||
String value;
|
String value;
|
||||||
CharSequence textBefore;
|
CharSequence textBefore;
|
||||||
CharSequence textAfter;
|
CharSequence textAfter;
|
||||||
PageNode page;
|
@Builder.Default
|
||||||
|
Set<PageNode> pages = new HashSet<>();
|
||||||
List<Rectangle2D> positions;
|
List<Rectangle2D> positions;
|
||||||
@Builder.Default
|
@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() {
|
public void removeFromGraph() {
|
||||||
|
|
||||||
getContainingNodes().forEach(node -> node.getEntities().remove(this));
|
getIntersectingNodes().forEach(node -> node.getEntities().remove(this));
|
||||||
getPage().getEntities().remove(this);
|
deepestFullyContainingNode = null;
|
||||||
|
getPages().forEach(page -> page.getEntities().remove(this));
|
||||||
setRemoved(true);
|
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
|
@Override
|
||||||
public int hashCode() {
|
public int hashCode() {
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("Entity [");
|
||||||
sb.append(value);
|
sb.append(value);
|
||||||
sb.append(boundary.start());
|
sb.append(", ");
|
||||||
|
sb.append(boundary);
|
||||||
|
sb.append(", Pages[");
|
||||||
|
pages.forEach(page -> {
|
||||||
sb.append(page.getNumber());
|
sb.append(page.getNumber());
|
||||||
positions.forEach(r -> {
|
sb.append(", ");
|
||||||
sb.append(r.getMinX());
|
|
||||||
sb.append(r.getMinY());
|
|
||||||
sb.append(r.getWidth());
|
|
||||||
sb.append(r.getHeight());
|
|
||||||
});
|
});
|
||||||
return Hashing.murmur3_128().hashString(sb.toString(), StandardCharsets.UTF_8).hashCode();
|
return Hashing.murmur3_128().hashString(sb.toString(), StandardCharsets.UTF_8).hashCode();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -7,6 +7,11 @@ public enum NodeType {
|
|||||||
return "Section";
|
return "Section";
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
HEADLINE {
|
||||||
|
public String toString() {
|
||||||
|
return "Headline";
|
||||||
|
}
|
||||||
|
},
|
||||||
PARAGRAPH {
|
PARAGRAPH {
|
||||||
public String toString() {
|
public String toString() {
|
||||||
|
|
||||||
@ -24,5 +29,23 @@ public enum NodeType {
|
|||||||
|
|
||||||
return "Cell";
|
return "Cell";
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
IMAGE {
|
||||||
|
public String toString() {
|
||||||
|
|
||||||
|
return "Image";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
HEADER {
|
||||||
|
public String toString() {
|
||||||
|
|
||||||
|
return "Header";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
FOOTER {
|
||||||
|
public String toString() {
|
||||||
|
|
||||||
|
return "Footer";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,83 +3,68 @@ package com.iqser.red.service.redaction.v1.server.document.graph.nodes;
|
|||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
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.nodes.SemanticNode;
|
||||||
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.ConcatenatedTextBlock;
|
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 com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlockCollector;
|
||||||
|
|
||||||
import lombok.AccessLevel;
|
import lombok.AccessLevel;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
|
||||||
import lombok.EqualsAndHashCode;
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
import lombok.experimental.FieldDefaults;
|
import lombok.experimental.FieldDefaults;
|
||||||
|
|
||||||
@Data
|
@Getter
|
||||||
|
@Setter
|
||||||
@Builder
|
@Builder
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
@FieldDefaults(level = AccessLevel.PRIVATE)
|
@FieldDefaults(level = AccessLevel.PRIVATE)
|
||||||
public class PageNode implements DocumentGraphNode{
|
public class PageNode {
|
||||||
|
|
||||||
Integer number;
|
Integer number;
|
||||||
Integer height;
|
Integer height;
|
||||||
Integer width;
|
Integer width;
|
||||||
List<DocumentGraphNode> mainBody;
|
Integer rotation;
|
||||||
AtomicTextBlock header;
|
|
||||||
AtomicTextBlock footer;
|
@EqualsAndHashCode.Exclude
|
||||||
|
List<SemanticNode> mainBody;
|
||||||
|
@EqualsAndHashCode.Exclude
|
||||||
|
HeaderNode header;
|
||||||
|
@EqualsAndHashCode.Exclude
|
||||||
|
FooterNode footer;
|
||||||
|
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
@EqualsAndHashCode.Exclude
|
@EqualsAndHashCode.Exclude
|
||||||
Set<EntityNode> entities = new HashSet<>();
|
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());
|
return mainBody.stream().filter(SemanticNode::isTerminal).map(SemanticNode::getTerminalTextBlock).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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String toString() {
|
public String toString() {
|
||||||
|
|
||||||
return header.getSearchText() + buildTextBlock().toString() + footer.getSearchText();
|
return String.valueOf(number);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
|
||||||
|
return number;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
package com.iqser.red.service.redaction.v1.server.document.graph.nodes;
|
package com.iqser.red.service.redaction.v1.server.document.graph.nodes;
|
||||||
|
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Set;
|
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.AccessLevel;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
@ -17,17 +19,19 @@ import lombok.experimental.FieldDefaults;
|
|||||||
@Builder
|
@Builder
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
@FieldDefaults(level = AccessLevel.PRIVATE)
|
@FieldDefaults(level = AccessLevel.PRIVATE)
|
||||||
public class ParagraphNode implements DocumentGraphNode {
|
public class ParagraphNode extends PageElement implements SemanticNode {
|
||||||
|
|
||||||
String tocId;
|
List<Integer> tocId;
|
||||||
Integer numberOnPage;
|
TextBlock terminalTextBlock;
|
||||||
Integer numberInSection;
|
|
||||||
|
@Builder.Default
|
||||||
|
boolean terminal = true;
|
||||||
|
|
||||||
@EqualsAndHashCode.Exclude
|
@EqualsAndHashCode.Exclude
|
||||||
SectionNode parentSection;
|
TableOfContents tableOfContents;
|
||||||
|
|
||||||
@EqualsAndHashCode.Exclude
|
@EqualsAndHashCode.Exclude
|
||||||
PageNode page;
|
Set<PageNode> pages;
|
||||||
AtomicTextBlock atomicTextBlock;
|
|
||||||
|
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
@EqualsAndHashCode.Exclude
|
@EqualsAndHashCode.Exclude
|
||||||
@ -35,36 +39,14 @@ public class ParagraphNode implements DocumentGraphNode {
|
|||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AtomicTextBlock buildTextBlock() {
|
public TextBlock buildTextBlock() {
|
||||||
|
|
||||||
return atomicTextBlock;
|
return terminalTextBlock;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public DocumentGraphNode getParent() {
|
|
||||||
|
|
||||||
return parentSection;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean isTerminal() {
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String toString() {
|
public String toString() {
|
||||||
|
|
||||||
return tocId + ": " + atomicTextBlock.toString();
|
return tocId + ": " + NodeType.PARAGRAPH + ": " + terminalTextBlock.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public Stream<DocumentGraphNode> streamAllSubNodes() {
|
|
||||||
|
|
||||||
return Stream.of(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,12 +3,12 @@ package com.iqser.red.service.redaction.v1.server.document.graph.nodes;
|
|||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
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.TableOfContents;
|
||||||
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.ConcatenatedTextBlock;
|
|
||||||
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlockCollector;
|
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.AccessLevel;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
@ -23,86 +23,44 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
@Builder
|
@Builder
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
@FieldDefaults(level = AccessLevel.PRIVATE)
|
@FieldDefaults(level = AccessLevel.PRIVATE)
|
||||||
public class SectionNode implements DocumentGraphNode {
|
public class SectionNode extends PageElement implements SemanticNode {
|
||||||
|
|
||||||
String tocId;
|
List<Integer> tocId;
|
||||||
Integer numberOnPage;
|
|
||||||
|
TextBlock textBlock;
|
||||||
@EqualsAndHashCode.Exclude
|
@EqualsAndHashCode.Exclude
|
||||||
TableOfContents tableOfContents;
|
TableOfContents tableOfContents;
|
||||||
@EqualsAndHashCode.Exclude
|
@EqualsAndHashCode.Exclude
|
||||||
DocumentGraphNode parentSection;
|
Set<PageNode> pages;
|
||||||
|
|
||||||
|
|
||||||
AtomicTextBlock headline;
|
|
||||||
|
|
||||||
List<SectionNode> subSections;
|
|
||||||
List<ParagraphNode> paragraphs;
|
|
||||||
List<TableNode> tables;
|
|
||||||
@EqualsAndHashCode.Exclude
|
|
||||||
List<PageNode> pages;
|
|
||||||
|
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
@EqualsAndHashCode.Exclude
|
@EqualsAndHashCode.Exclude
|
||||||
Set<EntityNode> entities = new HashSet<>();
|
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
|
@Override
|
||||||
public String toString() {
|
public String toString() {
|
||||||
|
|
||||||
return tocId + ": " + headline.toString();
|
return tocId.toString() + ": " + NodeType.SECTION + ": " + buildTextBlock().getSearchText();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Override
|
public HeadlineNode getHeadline() {
|
||||||
public ConcatenatedTextBlock buildTextBlock() {
|
|
||||||
|
|
||||||
return streamAllSubNodes().map(DocumentGraphNode::getAtomicTextBlock).collect(new TextBlockCollector());
|
return streamChildren().filter(node -> node instanceof HeadlineNode)
|
||||||
}
|
.map(node -> (HeadlineNode) node)
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new NotFoundException("Section has no Headline!"));
|
||||||
@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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -1,10 +1,17 @@
|
|||||||
package com.iqser.red.service.redaction.v1.server.document.graph.nodes;
|
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.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.stream.Stream;
|
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.AccessLevel;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
@ -17,43 +24,58 @@ import lombok.experimental.FieldDefaults;
|
|||||||
@Builder
|
@Builder
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
@FieldDefaults(level = AccessLevel.PRIVATE)
|
@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
|
@EqualsAndHashCode.Exclude
|
||||||
TableNode parentTable;
|
TableOfContents tableOfContents;
|
||||||
Integer numberOnPage;
|
|
||||||
AtomicTextBlock atomicTextBlock;
|
@EqualsAndHashCode.Exclude
|
||||||
PageNode page;
|
Set<PageNode> pages;
|
||||||
|
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
@EqualsAndHashCode.Exclude
|
@EqualsAndHashCode.Exclude
|
||||||
List<EntityNode> entities = new LinkedList<>();
|
Set<EntityNode> entities = new HashSet<>();
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AtomicTextBlock buildTextBlock() {
|
public Map<PageNode, Rectangle2D> getBBox() {
|
||||||
|
|
||||||
return atomicTextBlock;
|
Map<PageNode, Rectangle2D> bBoxPerPage = new HashMap<>();
|
||||||
}
|
pages.forEach(page -> bBoxPerPage.put(page, bBox));
|
||||||
|
return bBoxPerPage;
|
||||||
|
|
||||||
@Override
|
|
||||||
public DocumentGraphNode getParent() {
|
|
||||||
|
|
||||||
return parentTable;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean isTerminal() {
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@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);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
package com.iqser.red.service.redaction.v1.server.document.graph.nodes;
|
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.List;
|
||||||
import java.util.function.Function;
|
import java.util.Set;
|
||||||
import java.util.stream.Stream;
|
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.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.TextBlockCollector;
|
||||||
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.ConcatenatedTextBlock;
|
|
||||||
|
|
||||||
import lombok.AccessLevel;
|
import lombok.AccessLevel;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
@ -20,69 +21,55 @@ import lombok.experimental.FieldDefaults;
|
|||||||
@Builder
|
@Builder
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
@FieldDefaults(level = AccessLevel.PRIVATE)
|
@FieldDefaults(level = AccessLevel.PRIVATE)
|
||||||
public class TableNode implements DocumentGraphNode {
|
public class TableNode extends PageElement implements SemanticNode {
|
||||||
|
|
||||||
Integer id;
|
List<Integer> tocId;
|
||||||
String tocId;
|
|
||||||
Integer numberOfRows;
|
|
||||||
Integer numberOfCols;
|
|
||||||
Integer numberOnPage;
|
|
||||||
List<TableCellNode> tableHeaders;
|
|
||||||
List<List<TableCellNode>> tableCells;
|
|
||||||
TableOfContents tableOfContents;
|
TableOfContents tableOfContents;
|
||||||
|
|
||||||
|
Integer numberOfRows;
|
||||||
|
Integer numberOfCols;
|
||||||
|
|
||||||
|
TextBlock textBlock;
|
||||||
@EqualsAndHashCode.Exclude
|
@EqualsAndHashCode.Exclude
|
||||||
SectionNode parentSection;
|
Set<PageNode> pages;
|
||||||
@EqualsAndHashCode.Exclude
|
|
||||||
List<PageNode> pages;
|
|
||||||
|
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
@EqualsAndHashCode.Exclude
|
@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
|
@Override
|
||||||
public ConcatenatedTextBlock buildTextBlock() {
|
public TextBlock buildTextBlock() {
|
||||||
|
|
||||||
return streamTableCells().map(TableCellNode::getAtomicTextBlock).collect(new TextBlockCollector());
|
if (textBlock == null) {
|
||||||
|
textBlock = streamAllSubNodes().filter(SemanticNode::isTerminal).map(SemanticNode::getTerminalTextBlock).collect(new TextBlockCollector());
|
||||||
}
|
}
|
||||||
|
return textBlock;
|
||||||
@Override
|
|
||||||
public Stream<DocumentGraphNode> streamAllSubNodes() {
|
|
||||||
|
|
||||||
return streamTableCells().map(Function.identity());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public DocumentGraphNode getParent() {
|
public String toString() {
|
||||||
|
|
||||||
return parentSection;
|
return tocId.toString() + ": " + NodeType.TABLE + ": " + buildTextBlock().getSearchText();
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public PageNode getPage() {
|
|
||||||
|
|
||||||
return pages.get(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,8 @@ import java.awt.geom.Rectangle2D;
|
|||||||
import java.util.List;
|
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.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.AccessLevel;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
@ -22,6 +23,8 @@ import lombok.experimental.FieldDefaults;
|
|||||||
public class AtomicTextBlock implements TextBlock {
|
public class AtomicTextBlock implements TextBlock {
|
||||||
|
|
||||||
Long id;
|
Long id;
|
||||||
|
Integer numberOnPage;
|
||||||
|
PageNode page;
|
||||||
|
|
||||||
//string coordinates
|
//string coordinates
|
||||||
Boundary boundary;
|
Boundary boundary;
|
||||||
@ -33,16 +36,10 @@ public class AtomicTextBlock implements TextBlock {
|
|||||||
List<Rectangle2D> positions;
|
List<Rectangle2D> positions;
|
||||||
|
|
||||||
@EqualsAndHashCode.Exclude
|
@EqualsAndHashCode.Exclude
|
||||||
DocumentGraphNode parent;
|
SemanticNode parent;
|
||||||
|
|
||||||
|
|
||||||
public int indexOf(String searchTerm) {
|
|
||||||
|
|
||||||
int pos = searchText.indexOf(searchTerm);
|
|
||||||
return pos == -1 ? -1 : pos + boundary.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
@Override
|
||||||
public int numberOfLines() {
|
public int numberOfLines() {
|
||||||
|
|
||||||
return lineBreaks.size();
|
return lineBreaks.size();
|
||||||
@ -56,36 +53,38 @@ public class AtomicTextBlock implements TextBlock {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Override
|
||||||
public int getNextLinebreak(int fromIndex) {
|
public int getNextLinebreak(int fromIndex) {
|
||||||
|
|
||||||
return lineBreaks.stream()//
|
return lineBreaks.stream()//
|
||||||
.filter(linebreak -> linebreak > fromIndex) //
|
.filter(linebreak -> linebreak > fromIndex - boundary.start()) //
|
||||||
.findFirst() //
|
.findFirst() //
|
||||||
.orElse(searchText.length()) + boundary.start();
|
.orElse(searchText.length()) + boundary.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Override
|
||||||
public int getPreviousLinebreak(int fromIndex) {
|
public int getPreviousLinebreak(int fromIndex) {
|
||||||
|
|
||||||
return lineBreaks.stream()//
|
return lineBreaks.stream()//
|
||||||
.filter(linebreak -> linebreak <= fromIndex)//
|
.filter(linebreak -> linebreak <= fromIndex - boundary.start())//
|
||||||
.reduce((a, b) -> b)//
|
.reduce((a, b) -> b)//
|
||||||
.orElse(0) + boundary.start();
|
.orElse(0) + boundary.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Override
|
||||||
public Rectangle2D getPosition(int stringIdx) {
|
public Rectangle2D getPosition(int stringIdx) {
|
||||||
|
|
||||||
return positions.get(stringIdxToPositionIdx.get(stringIdx - boundary.start()));
|
return positions.get(stringIdxToPositionIdx.get(stringIdx - boundary.start()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Override
|
||||||
public List<Rectangle2D> getPositions(Boundary boundary) {
|
public List<Rectangle2D> getPositions(Boundary boundary) {
|
||||||
|
|
||||||
if (!containsBoundary(boundary)) {
|
if (!containsBoundary(boundary)) {
|
||||||
throw new IndexOutOfBoundsException(format("%s is out of bounds for %s",
|
throw new IndexOutOfBoundsException(format("%s is out of bounds for %s", boundary, this.boundary));
|
||||||
boundary,
|
|
||||||
this.boundary));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (boundary.end() == this.boundary.end()) {
|
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
|
@Override
|
||||||
public String toString() {
|
public String toString() {
|
||||||
|
|
||||||
|
|||||||
@ -18,33 +18,25 @@ import lombok.experimental.FieldDefaults;
|
|||||||
public class ConcatenatedTextBlock implements TextBlock, Supplier<ConcatenatedTextBlock> {
|
public class ConcatenatedTextBlock implements TextBlock, Supplier<ConcatenatedTextBlock> {
|
||||||
|
|
||||||
List<AtomicTextBlock> atomicTextBlocks;
|
List<AtomicTextBlock> atomicTextBlocks;
|
||||||
StringBuilder searchText;
|
String searchText;
|
||||||
Boundary boundary;
|
Boundary boundary;
|
||||||
|
|
||||||
|
|
||||||
public ConcatenatedTextBlock(List<AtomicTextBlock> atomicTextBlocks) {
|
public ConcatenatedTextBlock(List<AtomicTextBlock> atomicTextBlocks) {
|
||||||
|
|
||||||
this.atomicTextBlocks = new LinkedList<>();
|
this.atomicTextBlocks = new LinkedList<>();
|
||||||
this.searchText = new StringBuilder();
|
|
||||||
if (atomicTextBlocks.isEmpty()) {
|
if (atomicTextBlocks.isEmpty()) {
|
||||||
boundary = new Boundary(-1, -1);
|
boundary = new Boundary(-1, -1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var firstTextBlock = atomicTextBlocks.get(0);
|
var firstTextBlock = atomicTextBlocks.get(0);
|
||||||
this.atomicTextBlocks.add(firstTextBlock);
|
this.atomicTextBlocks.add(firstTextBlock);
|
||||||
this.searchText.append(firstTextBlock.getSearchText());
|
|
||||||
boundary = new Boundary(firstTextBlock.getBoundary().start(), firstTextBlock.getBoundary().end());
|
boundary = new Boundary(firstTextBlock.getBoundary().start(), firstTextBlock.getBoundary().end());
|
||||||
|
|
||||||
atomicTextBlocks.subList(1, atomicTextBlocks.size()).forEach(this::concat);
|
atomicTextBlocks.subList(1, atomicTextBlocks.size()).forEach(this::concat);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public ConcatenatedTextBlock(AtomicTextBlock atomicTextBlocks) {
|
|
||||||
|
|
||||||
new ConcatenatedTextBlock(List.of(atomicTextBlocks));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public ConcatenatedTextBlock concat(TextBlock textBlock) {
|
public ConcatenatedTextBlock concat(TextBlock textBlock) {
|
||||||
|
|
||||||
if (this.atomicTextBlocks.isEmpty()) {
|
if (this.atomicTextBlocks.isEmpty()) {
|
||||||
@ -53,44 +45,65 @@ public class ConcatenatedTextBlock implements TextBlock, Supplier<ConcatenatedTe
|
|||||||
} else if (boundary.end() != textBlock.getBoundary().start()) {
|
} 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));
|
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());
|
this.atomicTextBlocks.addAll(textBlock.getAtomicTextBlocks());
|
||||||
boundary.setEnd(textBlock.getBoundary().end());
|
boundary.setEnd(textBlock.getBoundary().end());
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public int indexOf(String searchTerm) {
|
private AtomicTextBlock getAtomicTextBlockByStringIndex(int stringIdx) {
|
||||||
|
|
||||||
int pos = this.searchText.indexOf(searchTerm);
|
return atomicTextBlocks.stream().filter(textBlock -> (textBlock.getBoundary().contains(stringIdx))).findAny().orElseThrow(IndexOutOfBoundsException::new);
|
||||||
return pos == -1 ? -1 : pos + boundary.start();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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() {
|
public int numberOfLines() {
|
||||||
|
|
||||||
return atomicTextBlocks.stream().map(AtomicTextBlock::getLineBreaks).mapToInt(List::size).sum();
|
return atomicTextBlocks.stream().map(AtomicTextBlock::getLineBreaks).mapToInt(List::size).sum();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Override
|
||||||
public int getNextLinebreak(int fromIndex) {
|
public int getNextLinebreak(int fromIndex) {
|
||||||
|
|
||||||
return getAtomicTextBlockByStringIndex(fromIndex).getNextLinebreak(fromIndex);
|
return getAtomicTextBlockByStringIndex(fromIndex).getNextLinebreak(fromIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Override
|
||||||
public int getPreviousLinebreak(int fromIndex) {
|
public int getPreviousLinebreak(int fromIndex) {
|
||||||
|
|
||||||
return getAtomicTextBlockByStringIndex(fromIndex).getPreviousLinebreak(fromIndex);
|
return getAtomicTextBlockByStringIndex(fromIndex).getPreviousLinebreak(fromIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Override
|
||||||
public Rectangle2D getPosition(int stringIdx) {
|
public Rectangle2D getPosition(int stringIdx) {
|
||||||
|
|
||||||
return getAtomicTextBlockByStringIndex(stringIdx).getPosition(stringIdx);
|
return getAtomicTextBlockByStringIndex(stringIdx).getPosition(stringIdx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Override
|
||||||
public List<Rectangle2D> getPositions(Boundary boundary) {
|
public List<Rectangle2D> getPositions(Boundary boundary) {
|
||||||
|
|
||||||
List<AtomicTextBlock> textBlocks = getAllAtomicTextBlocksPartiallyInStringIdxRange(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
|
@Override
|
||||||
public ConcatenatedTextBlock get() {
|
public ConcatenatedTextBlock get() {
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
|
||||||
|
return getSearchText();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import com.iqser.red.service.redaction.v1.server.document.graph.Boundary;
|
|||||||
|
|
||||||
public interface TextBlock extends CharSequence {
|
public interface TextBlock extends CharSequence {
|
||||||
|
|
||||||
CharSequence getSearchText();
|
String getSearchText();
|
||||||
|
|
||||||
|
|
||||||
List<AtomicTextBlock> getAtomicTextBlocks();
|
List<AtomicTextBlock> getAtomicTextBlocks();
|
||||||
@ -33,7 +33,20 @@ public interface TextBlock extends CharSequence {
|
|||||||
int numberOfLines();
|
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() {
|
default CharSequence getFirstLine() {
|
||||||
@ -62,4 +75,25 @@ public interface TextBlock extends CharSequence {
|
|||||||
return subSequence(boundary.start(), boundary.end());
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import java.util.stream.Collector;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
public class TextBlockCollector implements Collector<AtomicTextBlock, ConcatenatedTextBlock, ConcatenatedTextBlock> {
|
public class TextBlockCollector implements Collector<TextBlock, ConcatenatedTextBlock, TextBlock> {
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -22,7 +22,7 @@ public class TextBlockCollector implements Collector<AtomicTextBlock, Concatenat
|
|||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public BiConsumer<ConcatenatedTextBlock, AtomicTextBlock> accumulator() {
|
public BiConsumer<ConcatenatedTextBlock, TextBlock> accumulator() {
|
||||||
|
|
||||||
return ConcatenatedTextBlock::concat;
|
return ConcatenatedTextBlock::concat;
|
||||||
}
|
}
|
||||||
@ -36,9 +36,9 @@ public class TextBlockCollector implements Collector<AtomicTextBlock, Concatenat
|
|||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Function<ConcatenatedTextBlock, ConcatenatedTextBlock> finisher() {
|
public Function<ConcatenatedTextBlock, TextBlock> finisher() {
|
||||||
|
|
||||||
return Function.identity();
|
return a -> a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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.TableOfContents;
|
||||||
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.PageNode;
|
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.AtomicTextBlock;
|
||||||
|
import com.iqser.red.service.redaction.v1.server.document.graph.textblock.TextBlock;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class DocumentDataMapper {
|
public class DocumentDataMapper {
|
||||||
|
|
||||||
public DocumentData toDocumentData(DocumentGraph documentGraph) {
|
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();
|
List<PageData> pageData = documentGraph.getPages().stream().map(this::toPageData).toList();
|
||||||
TableOfContentsData tableOfContentsData = toTableOfContentsData(documentGraph.getTableOfContents());
|
TableOfContentsData tableOfContentsData = toTableOfContentsData(documentGraph.getTableOfContents());
|
||||||
return DocumentData.builder().atomicTextBlocks(atomicTextBlockData).pages(pageData).tableOfContents(tableOfContentsData).build();
|
return DocumentData.builder().atomicTextBlocks(atomicTextBlockData).pages(pageData).tableOfContents(tableOfContentsData).build();
|
||||||
@ -34,25 +39,36 @@ public class DocumentDataMapper {
|
|||||||
|
|
||||||
private TableOfContentsData.EntryData toEntryData(TableOfContents.Entry entry) {
|
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()
|
return TableOfContentsData.EntryData.builder()
|
||||||
.tocId(entry.id())
|
.tocId(toPrimitiveIntArray(entry.tocId()))
|
||||||
.subEntries(entry.children().stream().map(this::toEntryData).toList())
|
.subEntries(entry.children().stream().map(this::toEntryData).toList())
|
||||||
.type(entry.type())
|
.type(entry.type())
|
||||||
.atomicTextBlock(entry.node().isTerminal() ? entry.node().getAtomicTextBlock().getId() : -1L)
|
.atomicTextBlocks(atomicTextBlocks)
|
||||||
.page(Long.valueOf(entry.node().getPage().getNumber()))
|
.pages(entry.node().getPages().stream().map(PageNode::getNumber).toArray(Long[]::new))
|
||||||
.numberOnPage(entry.node().getNumberOnPage())
|
.numberOnPage(entry.node().getNumberOnPage())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private Long[] toAtomicTextBlockIds(TextBlock textBlock) {
|
||||||
|
|
||||||
|
return textBlock.getAtomicTextBlocks().stream().map(AtomicTextBlock::getId).toArray(Long[]::new);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private PageData toPageData(PageNode pageNode) {
|
private PageData toPageData(PageNode pageNode) {
|
||||||
|
|
||||||
return PageData.builder()
|
return PageData.builder()
|
||||||
.height(pageNode.getHeight())
|
.height(pageNode.getHeight())
|
||||||
.width(pageNode.getWidth())
|
.width(pageNode.getWidth())
|
||||||
.number(pageNode.getNumber())
|
.number(pageNode.getNumber())
|
||||||
.footer(pageNode.getFooter().getId())
|
|
||||||
.header(pageNode.getHeader().getId())
|
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,12 +1,16 @@
|
|||||||
package com.iqser.red.service.redaction.v1.server.document.services;
|
package com.iqser.red.service.redaction.v1.server.document.services;
|
||||||
|
|
||||||
import static java.lang.Math.toIntExact;
|
import static java.lang.Math.toIntExact;
|
||||||
import static java.lang.String.format;
|
import static java.lang.String.format;
|
||||||
|
|
||||||
import java.awt.geom.Rectangle2D;
|
import java.awt.geom.Rectangle2D;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.LinkedList;
|
import java.util.LinkedList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import org.apache.commons.lang3.NotImplementedException;
|
import org.apache.commons.lang3.NotImplementedException;
|
||||||
import org.springframework.stereotype.Service;
|
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.data.TableOfContentsData;
|
||||||
import com.iqser.red.service.redaction.v1.server.document.graph.Boundary;
|
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.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.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.ParagraphNode;
|
||||||
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.SectionNode;
|
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.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;
|
import com.iqser.red.service.redaction.v1.server.exception.NotFoundException;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@ -34,22 +40,20 @@ public class DocumentGraphMapper {
|
|||||||
|
|
||||||
Context context = new Context(documentData, new TableOfContents(), new LinkedList<>(), new LinkedList<>(), documentData.getAtomicTextBlocks());
|
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());
|
context.pages.addAll(documentData.getPages().stream().map(this::buildPage).toList());
|
||||||
buildNodesFromTableOfContents("", context);
|
|
||||||
DocumentGraph documentGraph= DocumentGraph.builder()
|
buildNodesFromTableOfContents(Collections.emptyList(), context);
|
||||||
.numberOfPages(documentData.getPages().size())
|
|
||||||
.pages(context.pages)
|
DocumentGraph documentGraph = DocumentGraph.builder().numberOfPages(documentData.getPages().size()).pages(context.pages).tableOfContents(context.tableOfContents).build();
|
||||||
.sections(context.sections)
|
documentGraph.setTextBlock(documentGraph.buildTextBlock());
|
||||||
.tableOfContents(context.tableOfContents)
|
|
||||||
.build();
|
|
||||||
documentGraph.setText(documentGraph.buildTextBlock());
|
|
||||||
return documentGraph;
|
return documentGraph;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private void buildNodesFromTableOfContents(String currentTocId, Context context) {
|
private void buildNodesFromTableOfContents(List<Integer> currentTocId, Context context) {
|
||||||
List <TableOfContentsData.EntryData> entries;
|
|
||||||
if(currentTocId.equals("")) {
|
List<TableOfContentsData.EntryData> entries;
|
||||||
|
if (currentTocId.size() == 0) {
|
||||||
entries = context.documentData().getTableOfContents().getEntries();
|
entries = context.documentData().getTableOfContents().getEntries();
|
||||||
} else {
|
} else {
|
||||||
entries = context.documentData().getTableOfContents().get(currentTocId).subEntries();
|
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()
|
SectionNode section = SectionNode.builder().entities(new HashSet<>()).pages(new HashSet<>()).tableOfContents(context.tableOfContents()).build();
|
||||||
.entities(new LinkedList<>())
|
|
||||||
.pages(new LinkedList<>())
|
|
||||||
.paragraphs(new LinkedList<>())
|
|
||||||
.tables(new LinkedList<>())
|
|
||||||
.subSections(new LinkedList<>())
|
|
||||||
.tableOfContents(context.tableOfContents())
|
|
||||||
.numberOnPage(entryData.numberOnPage())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
context.sections().add(section);
|
context.sections().add(section);
|
||||||
section.setHeadline(toAtomicTextBlock(context.atomicTextBlockData().get(toIntExact(entryData.atomicTextBlock())), section));
|
|
||||||
|
|
||||||
if (!currentTocId.equals("")) {
|
List<PageNode> pages = Arrays.stream(entryData.pages()).map(pageNumber -> getPage(pageNumber, context)).toList();
|
||||||
SectionNode parent = (SectionNode) context.tableOfContents().getEntryById(currentTocId).node();
|
pages.forEach(page -> page.getMainBody().add(section));
|
||||||
section.setParentSection(parent);
|
section.getPages().addAll(pages);
|
||||||
parent.getSubSections().add(section);
|
|
||||||
}
|
|
||||||
|
|
||||||
PageNode page = getPage(entryData.page(), context);
|
List<Integer> sectionId = context.tableOfContents.createNewChildEntryAndReturnId(currentTocId, NodeType.SECTION, section);
|
||||||
page.getMainBody().add(section);
|
|
||||||
section.getPages().add(page);
|
|
||||||
|
|
||||||
String sectionId = context.tableOfContents.createNewEntryAndReturnId(NodeType.SECTION, buildSummary(section.getHeadline()), section);
|
|
||||||
section.setTocId(sectionId);
|
section.setTocId(sectionId);
|
||||||
buildNodesFromTableOfContents(sectionId, context);
|
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();
|
SectionNode parentSection = (SectionNode) context.tableOfContents().getEntryById(currentTocId).node();
|
||||||
ParagraphNode paragraph = ParagraphNode.builder().numberOnPage(entryData.numberOnPage()).page(page).parentSection(parentSection).build();
|
ParagraphNode paragraph = ParagraphNode.builder().pages(pages).build();
|
||||||
AtomicTextBlock atomicTextBlock = toAtomicTextBlock(context.atomicTextBlockData.get(toIntExact(entryData.atomicTextBlock())), paragraph);
|
TextBlock textBlock = toTextBlock(entryData.atomicTextBlocks(), context, paragraph);
|
||||||
paragraph.setAtomicTextBlock(atomicTextBlock);
|
|
||||||
|
|
||||||
if (!page.getMainBody().contains(parentSection)) {
|
paragraph.setTerminalTextBlock(textBlock);
|
||||||
|
|
||||||
|
pages.forEach(page -> page.getMainBody().add(paragraph));
|
||||||
|
for (PageNode page : pages) {
|
||||||
|
if (!parentSection.getPages().contains(page)) {
|
||||||
parentSection.getPages().add(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);
|
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();
|
return Arrays.stream(atomicTextBlockIds)
|
||||||
AtomicTextBlock header = toAtomicTextBlock(context.atomicTextBlockData().get(toIntExact(p.getHeader())), page);
|
.map(atomicTextBlockId -> toAtomicTextBlock(context.atomicTextBlockData.get(toIntExact(atomicTextBlockId)), parent))
|
||||||
AtomicTextBlock footer = toAtomicTextBlock(context.atomicTextBlockData().get(toIntExact(p.getFooter())), page);
|
.collect(new TextBlockCollector());
|
||||||
page.setHeader(header);
|
|
||||||
page.setFooter(footer);
|
|
||||||
return page;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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 PageNode.builder().height(p.getHeight()).width(p.getWidth()).number(p.getNumber()).mainBody(new LinkedList<>()).build();
|
||||||
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 AtomicTextBlock toAtomicTextBlock(AtomicTextBlockData atomicTextBlockData, DocumentGraphNode parent) {
|
private AtomicTextBlock toAtomicTextBlock(AtomicTextBlockData atomicTextBlockData, SemanticNode parent) {
|
||||||
|
|
||||||
return AtomicTextBlock.builder()
|
return AtomicTextBlock.builder()
|
||||||
.id(atomicTextBlockData.getId())
|
.id(atomicTextBlockData.getId())
|
||||||
|
|||||||
@ -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));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -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) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,5 +1,39 @@
|
|||||||
package com.iqser.red.service.redaction.v1.server.parsing;
|
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.RedTextPosition;
|
||||||
import com.iqser.red.service.redaction.v1.server.parsing.model.TextPositionSequence;
|
import com.iqser.red.service.redaction.v1.server.parsing.model.TextPositionSequence;
|
||||||
import com.iqser.red.service.redaction.v1.server.tableextraction.model.Ruling;
|
import com.iqser.red.service.redaction.v1.server.tableextraction.model.Ruling;
|
||||||
@ -8,23 +42,6 @@ import lombok.Getter;
|
|||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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
|
@Slf4j
|
||||||
public class PDFLinesTextStripper extends PDFTextStripper {
|
public class PDFLinesTextStripper extends PDFTextStripper {
|
||||||
|
|
||||||
@ -264,8 +281,8 @@ public class PDFLinesTextStripper extends PDFTextStripper {
|
|||||||
// Remove false sequence ends (whitespaces)
|
// Remove false sequence ends (whitespaces)
|
||||||
if (previous != null && sublist.get(0).getYDirAdj() == previous.getYDirAdj() && sublist.get(0)
|
if (previous != null && sublist.get(0).getYDirAdj() == previous.getYDirAdj() && sublist.get(0)
|
||||||
.getXDirAdj() - (previous.getXDirAdj() + previous.getWidthDirAdj()) < 0.01) {
|
.getXDirAdj() - (previous.getXDirAdj() + previous.getWidthDirAdj()) < 0.01) {
|
||||||
for (TextPosition t : sublist) {
|
for (TextPosition textPosition : sublist) {
|
||||||
textPositionSequences.get(textPositionSequences.size() - 1).add(t);
|
textPositionSequences.get(textPositionSequences.size() - 1).add(textPosition);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
textPositionSequences.add(new TextPositionSequence(sublist, pageNumber));
|
textPositionSequences.add(new TextPositionSequence(sublist, pageNumber));
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
package com.iqser.red.service.redaction.v1.server.redaction.model;
|
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.AccessLevel;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|||||||
@ -12,7 +12,7 @@ public class PdfImage {
|
|||||||
private RedRectangle2D position;
|
private RedRectangle2D position;
|
||||||
@NonNull
|
@NonNull
|
||||||
private ImageType imageType;
|
private ImageType imageType;
|
||||||
private boolean isAppendedToParagraph;
|
private boolean isAppendedToSection;
|
||||||
@NonNull
|
@NonNull
|
||||||
private boolean hasTransparency;
|
private boolean hasTransparency;
|
||||||
@NonNull
|
@NonNull
|
||||||
|
|||||||
@ -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");
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,20 +1,16 @@
|
|||||||
package com.iqser.red.service.redaction.v1.server.redaction.service;
|
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.Dictionary;
|
||||||
import com.iqser.red.service.redaction.v1.server.redaction.model.Entity;
|
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.redaction.model.SearchableText;
|
||||||
import com.iqser.red.service.redaction.v1.server.settings.RedactionServiceSettings;
|
|
||||||
|
|
||||||
import io.micrometer.core.annotation.Timed;
|
import io.micrometer.core.annotation.Timed;
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.experimental.UtilityClass;
|
import lombok.experimental.UtilityClass;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@UtilityClass
|
@UtilityClass
|
||||||
public class SurroundingWordsService {
|
public class SurroundingWordsService {
|
||||||
|
|||||||
@ -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()) {
|
if (this.values.isEmpty()) {
|
||||||
return new ArrayList<>();
|
return new ArrayList<>();
|
||||||
}
|
}
|
||||||
CharSequence subSequence = text.subSequence(startOffset, startOffset + text.length());
|
CharSequence subSequence = text.subSequence(region.start(), region.end());
|
||||||
if (this.pattern != null) {
|
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 {
|
} 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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,6 @@ package com.iqser.red.service.redaction.v1.server.segmentation;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Iterator;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
@ -80,8 +79,7 @@ public class SectionsBuilderService {
|
|||||||
previousTable = chunkBlock.getTables().get(chunkBlock.getTables().size() - 1);
|
previousTable = chunkBlock.getTables().get(chunkBlock.getTables().size() - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (current instanceof Table) {
|
if (current instanceof Table table) {
|
||||||
Table table = (Table) current;
|
|
||||||
// Distribute header information for subsequent tables
|
// Distribute header information for subsequent tables
|
||||||
mergeTableMetadata(table, previousTable);
|
mergeTableMetadata(table, previousTable);
|
||||||
previousTable = table;
|
previousTable = table;
|
||||||
@ -114,46 +112,49 @@ public class SectionsBuilderService {
|
|||||||
|
|
||||||
public void addImagesToSections(Document document) {
|
public void addImagesToSections(Document document) {
|
||||||
|
|
||||||
Map<Integer, List<Section>> paragraphMap = new HashMap<>();
|
Map<Integer, List<Section>> sectionMap = new HashMap<>();
|
||||||
for (Section paragraph : document.getSections()) {
|
for (Section section : document.getSections()) {
|
||||||
for (AbstractTextContainer container : paragraph.getPageBlocks()) {
|
for (AbstractTextContainer container : section.getPageBlocks()) {
|
||||||
|
|
||||||
paragraphMap.computeIfAbsent(container.getPage(), c -> new ArrayList<>()).add(paragraph);
|
|
||||||
|
|
||||||
|
List<Section> sectionsOnPage = sectionMap.computeIfAbsent(container.getPage(), c -> new ArrayList<>());
|
||||||
|
if (sectionsOnPage.contains(section)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sectionsOnPage.add(section);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (paragraphMap.isEmpty()) {
|
if (sectionMap.isEmpty()) {
|
||||||
Section paragraph = new Section();
|
Section section = new Section();
|
||||||
document.getSections().add(paragraph);
|
document.getSections().add(section);
|
||||||
paragraphMap.computeIfAbsent(1, x -> new ArrayList<>()).add(paragraph);
|
sectionMap.computeIfAbsent(1, x -> new ArrayList<>()).add(section);
|
||||||
}
|
}
|
||||||
|
|
||||||
// first page is always a paragraph, else we can't process pages 1..N,
|
// first page is always a paragraph, else we can't process pages 1..N,
|
||||||
// where N is the first found page with a paragraph
|
// where N is the first found page with a paragraph
|
||||||
if (paragraphMap.get(1) == null) {
|
if (sectionMap.get(1) == null) {
|
||||||
Section paragraph = new Section();
|
Section section = new Section();
|
||||||
document.getSections().add(paragraph);
|
document.getSections().add(section);
|
||||||
paragraphMap.computeIfAbsent(1, x -> new ArrayList<>()).add(paragraph);
|
sectionMap.computeIfAbsent(1, x -> new ArrayList<>()).add(section);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (Page page : document.getPages()) {
|
for (Page page : document.getPages()) {
|
||||||
for (PdfImage image : page.getImages()) {
|
for (PdfImage image : page.getImages()) {
|
||||||
List<Section> paragraphsOnPage = paragraphMap.get(page.getPageNumber());
|
List<Section> sectionsOnPage = sectionMap.get(page.getPageNumber());
|
||||||
if (paragraphsOnPage == null) {
|
if (sectionsOnPage == null) {
|
||||||
int i = page.getPageNumber();
|
int i = page.getPageNumber();
|
||||||
while (paragraphsOnPage == null) {
|
while (sectionsOnPage == null) {
|
||||||
paragraphsOnPage = paragraphMap.get(i);
|
sectionsOnPage = sectionMap.get(i);
|
||||||
i--;
|
i--;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (Section paragraph : paragraphsOnPage) {
|
for (Section section : sectionsOnPage) {
|
||||||
Float xMin = null;
|
Float xMin = null;
|
||||||
Float yMin = null;
|
Float yMin = null;
|
||||||
Float xMax = null;
|
Float xMax = null;
|
||||||
Float yMax = null;
|
Float yMax = null;
|
||||||
|
|
||||||
for (AbstractTextContainer abs : paragraph.getPageBlocks()) {
|
for (AbstractTextContainer abs : section.getPageBlocks()) {
|
||||||
if (abs.getPage() != page.getPageNumber()) {
|
if (abs.getPage() != page.getPageNumber()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -197,14 +198,14 @@ public class SectionsBuilderService {
|
|||||||
|
|
||||||
if (xMin != null && xMax != null && yMin != null && yMax != null && image.getPosition().getX() >= xMin && image.getPosition()
|
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) {
|
.getX() <= xMax && image.getPosition().getY() >= yMin && image.getPosition().getY() <= yMax) {
|
||||||
paragraph.getImages().add(image);
|
section.getImages().add(image);
|
||||||
image.setAppendedToParagraph(true);
|
image.setAppendedToSection(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!image.isAppendedToParagraph()) {
|
if (!image.isAppendedToSection()) {
|
||||||
log.debug("Image uses first paragraph");
|
log.debug("Image uses first paragraph");
|
||||||
paragraphsOnPage.get(0).getImages().add(image);
|
sectionsOnPage.get(0).getImages().add(image);
|
||||||
image.setAppendedToParagraph(true);
|
image.setAppendedToSection(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -242,35 +243,16 @@ public class SectionsBuilderService {
|
|||||||
private Section buildTextBlock(List<AbstractTextContainer> wordBlockList, String lastHeadline) {
|
private Section buildTextBlock(List<AbstractTextContainer> wordBlockList, String lastHeadline) {
|
||||||
|
|
||||||
Section section = new Section();
|
Section section = new Section();
|
||||||
TextBlock textBlock = null;
|
|
||||||
|
|
||||||
int pageBefore = -1;
|
for (AbstractTextContainer container : wordBlockList) {
|
||||||
boolean splitByTable = false;
|
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());
|
|
||||||
} else {
|
|
||||||
if (lastHeadline == null || lastHeadline.isEmpty()) {
|
if (lastHeadline == null || lastHeadline.isEmpty()) {
|
||||||
table.setHeadline("Text in table");
|
table.setHeadline("Text in table");
|
||||||
} else {
|
} 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);
|
section.getPageBlocks().add(table);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -66,4 +66,10 @@ public abstract class AbstractTextContainer {
|
|||||||
return maxX - minX;
|
return maxX - minX;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public boolean intersectsY(AbstractTextContainer atc) {
|
||||||
|
|
||||||
|
return this.minY <= atc.getMaxY() && this.maxY >= atc.getMinY();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -453,22 +453,23 @@ public class AbstractTestWithDictionaries {
|
|||||||
@SneakyThrows
|
@SneakyThrows
|
||||||
protected AnalyzeRequest prepareStorage(String file) {
|
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
|
@SneakyThrows
|
||||||
protected AnalyzeRequest prepareStorage(String file, String cvServiceResponseFile) {
|
protected AnalyzeRequest prepareStorage(String file, String cvServiceResponseFile, String imageInfoFile) {
|
||||||
|
|
||||||
ClassPathResource pdfFileResource = new ClassPathResource(file);
|
ClassPathResource pdfFileResource = new ClassPathResource(file);
|
||||||
ClassPathResource cvServiceResponseFileResource = new ClassPathResource(cvServiceResponseFile);
|
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
|
@SneakyThrows
|
||||||
protected AnalyzeRequest prepareStorage(InputStream fileStream, InputStream cvServiceResponseFileStream) {
|
protected AnalyzeRequest prepareStorage(InputStream fileStream, InputStream cvServiceResponseFileStream, InputStream imageInfoStream) {
|
||||||
|
|
||||||
AnalyzeRequest request = AnalyzeRequest.builder()
|
AnalyzeRequest request = AnalyzeRequest.builder()
|
||||||
.dossierTemplateId(TEST_DOSSIER_TEMPLATE_ID)
|
.dossierTemplateId(TEST_DOSSIER_TEMPLATE_ID)
|
||||||
@ -477,6 +478,7 @@ public class AbstractTestWithDictionaries {
|
|||||||
.lastProcessed(OffsetDateTime.now())
|
.lastProcessed(OffsetDateTime.now())
|
||||||
.build();
|
.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.TABLES), cvServiceResponseFileStream);
|
||||||
storageService.storeObject(RedactionStorageService.StorageIdUtils.getStorageId(TEST_DOSSIER_ID, TEST_FILE_ID, FileType.ORIGIN), fileStream);
|
storageService.storeObject(RedactionStorageService.StorageIdUtils.getStorageId(TEST_DOSSIER_ID, TEST_FILE_ID, FileType.ORIGIN), fileStream);
|
||||||
|
|
||||||
|
|||||||
@ -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 static com.iqser.red.service.redaction.v1.server.utils.PdfDraw.drawRectangle2DList;
|
||||||
|
|
||||||
import java.awt.Color;
|
import java.awt.Color;
|
||||||
import java.awt.Shape;
|
|
||||||
import java.awt.geom.AffineTransform;
|
|
||||||
import java.awt.geom.Rectangle2D;
|
import java.awt.geom.Rectangle2D;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.LinkedList;
|
import java.util.LinkedList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@ -22,20 +18,20 @@ import org.springframework.beans.factory.annotation.Qualifier;
|
|||||||
import org.springframework.core.io.ClassPathResource;
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
|
||||||
import com.iqser.red.service.redaction.v1.model.FileAttribute;
|
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.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.EntityNode;
|
||||||
import com.iqser.red.service.redaction.v1.server.document.graph.nodes.PageNode;
|
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.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.graph.textblock.TextBlock;
|
||||||
import com.iqser.red.service.redaction.v1.server.document.services.DocumentDataMapper;
|
import com.iqser.red.service.redaction.v1.server.document.services.EntityCreationService;
|
||||||
import com.iqser.red.service.redaction.v1.server.document.services.DocumentGraphFactory;
|
|
||||||
import com.iqser.red.service.redaction.v1.server.redaction.model.Dictionary;
|
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.model.EntityType;
|
||||||
import com.iqser.red.service.redaction.v1.server.redaction.service.DictionaryService;
|
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.redaction.utils.SearchImplementation;
|
||||||
import com.iqser.red.service.redaction.v1.server.segmentation.PdfSegmentationService;
|
import com.iqser.red.service.redaction.v1.server.segmentation.PdfSegmentationService;
|
||||||
|
import com.iqser.red.service.redaction.v1.server.utils.PdfDraw;
|
||||||
|
|
||||||
import lombok.SneakyThrows;
|
import lombok.SneakyThrows;
|
||||||
|
|
||||||
@ -51,7 +47,7 @@ public class DocumentGraphIntegrationTest extends AbstractTestWithDictionaries {
|
|||||||
private DictionaryService dictionaryService;
|
private DictionaryService dictionaryService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private DocumentDataMapper documentDataMapper;
|
private EntityCreationService entityCreationService;
|
||||||
|
|
||||||
@Qualifier("kieContainer")
|
@Qualifier("kieContainer")
|
||||||
@Autowired
|
@Autowired
|
||||||
@ -77,18 +73,21 @@ public class DocumentGraphIntegrationTest extends AbstractTestWithDictionaries {
|
|||||||
|
|
||||||
List<EntityNode> foundEntities = new LinkedList<>();
|
List<EntityNode> foundEntities = new LinkedList<>();
|
||||||
for (var model : dictionary.getDictionaryModels()) {
|
for (var model : dictionary.getDictionaryModels()) {
|
||||||
findEntitiesWithSearchImplementationAndAddToGraph(document, model.getEntriesSearch(), EntityType.ENTITY, foundEntities, model.getType());
|
findEntitiesWithSearchImplementation(document, model.getEntriesSearch(), EntityType.ENTITY, foundEntities, model.getType());
|
||||||
findEntitiesWithSearchImplementationAndAddToGraph(document, model.getFalsePositiveSearch(), EntityType.FALSE_POSITIVE, foundEntities, model.getType());
|
findEntitiesWithSearchImplementation(document, model.getFalsePositiveSearch(), EntityType.FALSE_POSITIVE, foundEntities, model.getType());
|
||||||
findEntitiesWithSearchImplementationAndAddToGraph(document, model.getFalseRecommendationsSearch(), EntityType.FALSE_RECOMMENDATION, 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 kieSession = kieContainer.newKieSession();
|
||||||
kieSession.setGlobal("document", document);
|
kieSession.setGlobal("document", document);
|
||||||
|
|
||||||
document.getEntities().forEach(kieSession::insert);
|
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.getPages().forEach(kieSession::insert);
|
||||||
document.getSections().forEach(sec -> sec.getParagraphs().forEach(kieSession::insert));
|
|
||||||
kieSession.insert(FileAttribute.builder().label("Vertebrate Study").value("Yes").build());
|
kieSession.insert(FileAttribute.builder().label("Vertebrate Study").value("Yes").build());
|
||||||
|
|
||||||
kieSession.fireAllRules();
|
kieSession.fireAllRules();
|
||||||
|
|
||||||
drawAllEntities(filename, fileResource, document);
|
drawAllEntities(filename, fileResource, document);
|
||||||
@ -109,12 +108,12 @@ public class DocumentGraphIntegrationTest extends AbstractTestWithDictionaries {
|
|||||||
DocumentGraph document = documentGraphFactory.buildDocumentGraph(classifiedDoc);
|
DocumentGraph document = documentGraphFactory.buildDocumentGraph(classifiedDoc);
|
||||||
var start = System.currentTimeMillis();
|
var start = System.currentTimeMillis();
|
||||||
for (int i = 0; i < n; i++) {
|
for (int i = 0; i < n; i++) {
|
||||||
TextBlock textBlock = document.getText();
|
TextBlock textBlock = document.getTextBlock();
|
||||||
}
|
}
|
||||||
float durationMillis = ((float) (System.currentTimeMillis() - start));
|
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);
|
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();
|
start = System.currentTimeMillis();
|
||||||
for (int i = 0; i < n; i++) {
|
for (int i = 0; i < n; i++) {
|
||||||
section.buildTextBlock();
|
section.buildTextBlock();
|
||||||
@ -122,7 +121,7 @@ public class DocumentGraphIntegrationTest extends AbstractTestWithDictionaries {
|
|||||||
durationMillis = ((float) (System.currentTimeMillis() - start));
|
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);
|
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();
|
start = System.currentTimeMillis();
|
||||||
for (int i = 0; i < n; i++) {
|
for (int i = 0; i < n; i++) {
|
||||||
paragraph.buildTextBlock();
|
paragraph.buildTextBlock();
|
||||||
@ -147,34 +146,42 @@ public class DocumentGraphIntegrationTest extends AbstractTestWithDictionaries {
|
|||||||
dictionaryService.updateDictionary(TEST_DOSSIER_TEMPLATE_ID, TEST_DOSSIER_ID);
|
dictionaryService.updateDictionary(TEST_DOSSIER_TEMPLATE_ID, TEST_DOSSIER_ID);
|
||||||
Dictionary dictionary = dictionaryService.getDeepCopyDictionary(TEST_DOSSIER_TEMPLATE_ID, TEST_DOSSIER_ID);
|
Dictionary dictionary = dictionaryService.getDeepCopyDictionary(TEST_DOSSIER_TEMPLATE_ID, TEST_DOSSIER_ID);
|
||||||
|
|
||||||
int numberOfSearches = 25;
|
int numberOfRuns = 25;
|
||||||
float totalTime = 0;
|
float totalSearchTime = 0;
|
||||||
float totalGraphTime = 0;
|
float totalGraphTime = 0;
|
||||||
|
float totalInsertTime = 0;
|
||||||
List<EntityNode> foundEntities = new LinkedList<>();
|
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();
|
var graphStart = System.currentTimeMillis();
|
||||||
document = documentGraphFactory.buildDocumentGraph(classifiedDoc);
|
document = documentGraphFactory.buildDocumentGraph(classifiedDoc);
|
||||||
var graphTime = ((float) (System.currentTimeMillis() - graphStart)) / 1000;
|
var graphTime = System.currentTimeMillis() - graphStart;
|
||||||
totalGraphTime += graphTime;
|
totalGraphTime += graphTime;
|
||||||
var start = System.currentTimeMillis();
|
|
||||||
foundEntities = new LinkedList<>();
|
|
||||||
|
|
||||||
|
var searchStart = System.currentTimeMillis();
|
||||||
for (var model : dictionary.getDictionaryModels()) {
|
for (var model : dictionary.getDictionaryModels()) {
|
||||||
findEntitiesWithSearchImplementationAndAddToGraph(document, model.getEntriesSearch(), EntityType.ENTITY, foundEntities, model.getType());
|
findEntitiesWithSearchImplementation(document, model.getEntriesSearch(), EntityType.ENTITY, foundEntities, model.getType());
|
||||||
findEntitiesWithSearchImplementationAndAddToGraph(document, model.getFalsePositiveSearch(), EntityType.FALSE_POSITIVE, foundEntities, model.getType());
|
findEntitiesWithSearchImplementation(document, model.getFalsePositiveSearch(), EntityType.FALSE_POSITIVE, foundEntities, model.getType());
|
||||||
findEntitiesWithSearchImplementationAndAddToGraph(document, model.getFalseRecommendationsSearch(), EntityType.FALSE_RECOMMENDATION, foundEntities, model.getType());
|
findEntitiesWithSearchImplementation(document, model.getFalseRecommendationsSearch(), EntityType.FALSE_RECOMMENDATION, foundEntities, model.getType());
|
||||||
}
|
}
|
||||||
var time = ((float) (System.currentTimeMillis() - start)) / 1000;
|
var searchTime = System.currentTimeMillis() - searchStart;
|
||||||
totalTime += time;
|
totalSearchTime += searchTime;
|
||||||
System.out.printf("%d Search %fs; Graph construction %fs \n", i, time, graphTime);
|
|
||||||
|
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 %s s, average %.2f ms\n", numberOfRuns, ((float) totalGraphTime / 1000), totalGraphTime / numberOfRuns);
|
||||||
System.out.printf("%d Graph constructions took %f s, average %f s\n", numberOfSearches, totalGraphTime, totalGraphTime / numberOfSearches);
|
System.out.printf("%d Searches took %s s, average %.2f ms\n", numberOfRuns, ((float) totalSearchTime / 1000), totalSearchTime / numberOfRuns);
|
||||||
Set<EntityNode> distinctFoundEntities = new HashSet<>(foundEntities);
|
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", distinctFoundEntities.size(), document.getEntities().size());
|
System.out.printf("Found %d entities and saved %d\n", foundEntities.size(), document.getEntities().size());
|
||||||
//assert document.getEntities().size() == distinctFoundEntities.size();
|
assert document.getEntities().size() == foundEntities.size();
|
||||||
|
|
||||||
drawAllEntities(filename, fileResource, document);
|
drawAllEntities(filename, fileResource, document);
|
||||||
}
|
}
|
||||||
@ -187,31 +194,27 @@ public class DocumentGraphIntegrationTest extends AbstractTestWithDictionaries {
|
|||||||
PDDocument pdDocument = PDDocument.load(fileStream);
|
PDDocument pdDocument = PDDocument.load(fileStream);
|
||||||
|
|
||||||
for (PageNode page : document.getPages()) {
|
for (PageNode page : document.getPages()) {
|
||||||
AffineTransform mirrorY = new AffineTransform(1, 0, 0, -1, 0, page.getHeight() + 6);
|
|
||||||
List<Rectangle2D> entityPositionsOnPage = page.getEntities()
|
List<Rectangle2D> entityPositionsOnPage = page.getEntities()
|
||||||
.stream()
|
.stream()
|
||||||
.filter(EntityNode::isRedact)
|
.filter(EntityNode::isRedaction)
|
||||||
.map(EntityNode::getPositions)
|
.map(EntityNode::getPositions)
|
||||||
.flatMap(List::stream)
|
.flatMap(List::stream)
|
||||||
.map(mirrorY::createTransformedShape)
|
|
||||||
.map(Shape::getBounds2D)
|
|
||||||
.toList();
|
.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()) {
|
for (PageNode page : document.getPages()) {
|
||||||
AffineTransform mirrorY = new AffineTransform(1, 0, 0, -1, 0, page.getHeight() + 6);
|
|
||||||
List<Rectangle2D> entityPositionsOnPage = page.getEntities()
|
List<Rectangle2D> entityPositionsOnPage = page.getEntities()
|
||||||
.stream()
|
.stream()
|
||||||
.filter(e -> !e.isRedact())
|
.filter(e -> !e.isRedaction())
|
||||||
.map(EntityNode::getPositions)
|
.map(EntityNode::getPositions)
|
||||||
.flatMap(List::stream)
|
.flatMap(List::stream)
|
||||||
.map(mirrorY::createTransformedShape)
|
|
||||||
.map(Shape::getBounds2D)
|
|
||||||
.toList();
|
.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);
|
File outputFile = new File(tmpFileName);
|
||||||
pdDocument.save(outputFile);
|
pdDocument.save(outputFile);
|
||||||
@ -220,39 +223,16 @@ public class DocumentGraphIntegrationTest extends AbstractTestWithDictionaries {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private void findEntitiesWithSearchImplementationAndAddToGraph(DocumentGraph documentGraph,
|
private void findEntitiesWithSearchImplementation(DocumentGraph documentGraph,
|
||||||
SearchImplementation searchImplementation,
|
SearchImplementation searchImplementation,
|
||||||
EntityType entityType,
|
EntityType entityType,
|
||||||
List<EntityNode> foundEntities,
|
List<EntityNode> foundEntities,
|
||||||
String type) {
|
String type) {
|
||||||
|
|
||||||
documentGraph.getSections().forEach( section -> {
|
TextBlock textBlock = documentGraph.getTextBlock();
|
||||||
TextBlock textBlock = section.buildTextBlock();
|
searchImplementation.getMatches(textBlock, textBlock.getBoundary())
|
||||||
searchImplementation.getMatches(textBlock, textBlock.getBoundary().start())
|
|
||||||
.stream()
|
.stream()
|
||||||
.map(bounds -> section.createAndAddEntity(bounds, type, entityType))
|
.map(bounds -> EntityNode.initialEntityNode(bounds, type, entityType))
|
||||||
.forEach(foundEntities::add);
|
.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);
|
|
||||||
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.core.io.ClassPathResource;
|
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.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.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.document.services.DocumentGraphMapper;
|
||||||
import com.iqser.red.service.redaction.v1.server.segmentation.PdfSegmentationService;
|
import com.iqser.red.service.redaction.v1.server.segmentation.PdfSegmentationService;
|
||||||
|
|
||||||
@ -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)));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -1,27 +1,157 @@
|
|||||||
package com.iqser.red.service.redaction.v1.server.utils;
|
package com.iqser.red.service.redaction.v1.server.utils;
|
||||||
|
|
||||||
import java.awt.Color;
|
import java.awt.Color;
|
||||||
|
import java.awt.geom.Point2D;
|
||||||
import java.awt.geom.Rectangle2D;
|
import java.awt.geom.Rectangle2D;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
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.SneakyThrows;
|
||||||
|
import lombok.experimental.FieldDefaults;
|
||||||
import lombok.experimental.UtilityClass;
|
import lombok.experimental.UtilityClass;
|
||||||
|
|
||||||
@UtilityClass
|
@UtilityClass
|
||||||
public class PdfDraw {
|
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
|
@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 pdPage = document.getPage(pageNumber - 1);
|
||||||
var contentStream = new PDPageContentStream(document, pdPage, PDPageContentStream.AppendMode.APPEND, true);
|
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) {
|
for (var r : rectCollection) {
|
||||||
contentStream.addRect((float) r.getMinX(), (float) r.getMinY(), (float) r.getWidth(), (float) r.getHeight());
|
contentStream.addRect((float) r.getMinX(), (float) r.getMinY(), (float) r.getWidth(), (float) r.getHeight());
|
||||||
|
|
||||||
|
if (options.isStroke() && options.isFill()) {
|
||||||
|
contentStream.fillAndStroke();
|
||||||
|
} else if (options.isStroke()) {
|
||||||
contentStream.stroke();
|
contentStream.stroke();
|
||||||
|
} else if (options.isFill()) {
|
||||||
|
contentStream.fill();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
contentStream.close();
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"dossierId": "f889853e-4bf8-49a9-aae5-c38605c6ef40",
|
||||||
|
"fileId": "22ef63e29bb2a27db8497272336f6b32",
|
||||||
|
"data": []
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user