[Spring Boot] CAD 파일의 텍스트를 추출하고 엑셀화 하기

고리·2023년 1월 6일
0

Server

목록 보기
4/12
post-thumbnail

데이터 분석을 위해서 CAD 파일(.DWG)내의 텍스트를 추출해 엑셀화 해달라는 요구를 받았다. 앞선 게시물에서 텍스트 추출 방법을 살펴보았는데 이번 게시물에서는 위의 방법을 고도화할 것이다.

엑셀화를 위해서 Apache Poi를 사용하고 텍스트 추출을 위해 Aspose를 사용한다. 이를 위해 pom.xml에 종속성을 추가해 주자. 진행하는 프로젝트에서 필수적인 라이브러리인 aspose가 maven만을 지원하기 때문에 maven을 사용했다.

<dependency> <!-- 엑셀화를 위한 라이브러리-->
  <groupId>org.apache.poi</groupId>
  <artifactId>poi-ooxml</artifactId>
  <version>5.2.2</version>
</dependency>

<repositories> <!-- 캐드 파일에서 텍스트 추출을 위한 라이브러리 -->
  <repository>
    <id>AsposeJavaAPI</id>
    <name>Aspose Java API</name>
    <url>https://releases.afspose.com/repo/</url>
  </repository>
</repositories>
<dependency>
  <groupId>com.aspose</groupId>
  <artifactId>aspose-cad</artifactId>
  <version>20.9</version>
</dependency>

프로젝트의 구조는 아래와 같다.


텍스트 추출하기

src/main/resources/static 경로에 기업에서 진행한 프로젝트 폴더가 모여있다.

한 프로젝트 폴더는 여러 세부 폴더로 나뉘어져 있고 여러 폴더에 캐드파일이 나눠 저장되어 있다. 지금부터 이 많은 캐드 파일에서 텍스트를 추출해보자

src/main/java/com/anse/engineering/poi/Poi.java 파일에 아래의 함수를 작성하자

private static List<DwgFile> findFileInfo() {
    List<DwgFile> dwgFiles = new ArrayList<>();
    try {
        Set<String> directories = Stream.of(new File(SearchTextInDWGFile.getDataDir()).listFiles())
                .filter(File::isDirectory)
                .map(File::getName)
                .collect(Collectors.toSet());
        for (String directory: directories) {
            Map<String, String> fileInfo = SearchTextInDWGFile.searchDWGFileInDataDir(directory);
            for (Map.Entry<String, String> entry: fileInfo.entrySet()) {
                DwgFile dwgFile = new DwgFile(0, directory, entry.getKey(), entry.getValue());
                dwgFiles.add(dwgFile);
            }
        }
    } catch (NullPointerException e) {
        e.printStackTrace();
    }
    return dwgFiles;
}

사실 위 코드의 3번 줄은 아래와 같았다.

Set<String> directories = Stream.of(new File(SearchTextInDWGFile.getDataDir())
                .listFiles())
                .filter(file -> file.isDirectory())
                .map(File::getName)
                .collect(Collectors.toSet());

하지만 인텔리제이에서 아래의 경고를 띄운다.

Lambda can be replaced with method reference

왜 이런 경고를 띄웠나 보니까 filter(file -> file.isDirectory())에서 만약 file이 null이면 위의 람다식이 filter 메서드 내에서 lazily하게 계산된다. 이러면 try-catch block에서 NullPointerException이 발생한다. 하지만 처음에 본 코드처럼 작성한다면 filter 메서드가 호출되기 전에 NullPointerException이 발생한다.

코드별로 설명을 하자면

Set<String> directories =
Stream.of(new File(SearchTextInDWGFile.getDataDir()).listFiles())
                .filter(File::isDirectory)
                .map(File::getName)
                .collect(Collectors.toSet());

new File("Data가 들어있는 디렉토리 문자열").listFiles()를 통해 파일 목록을 Filelist로 반환한다. 반환된 list(컬렉션)의 저장 요소를 전달하는 Stream 객체를 Stream.of()로 생성한다. 이제 Stream이 컬렉션의 요소를 하나씩 참조해서 람다식으로 처리할 수 있게 해준다.

그 후 각 요소에 대해 중간처리와 최종처리를 수행한다. 요소(파일) 중 디렉토리인 요소의 이름을 가져와 Set 컬렉션으로 만들어 리턴한다.

for (String directory: directories) {
    Map<String, String> fileInfo = SearchTextInDWGFile.searchDWGFileInDataDir(directory);
    for (Map.Entry<String, String> entry: fileInfo.entrySet()) {
        DwgFile dwgFile = new DwgFile(0, directory, entry.getKey(), entry.getValue());
        dwgFiles.add(dwgFile);
    }
}

위에서 만든 디렉토리 이름 집합을 순회하며 searchDWGFileInDataDir 메서드에 directory이름을 인자로 넘겨 해당 디렉토리의 모든 dwg파일에서 텍스트를 추출하자.

지금까지 큰 코드 묶음 코드 두개를 살펴보았다. 각각 getDataDir() searchDWGFileInDataDir(directory) 메서드를 사용했는데 메서드의 정의를 보자


src/main/java/com/anse/engineering/aspose/SearchTextInDWGFile.java파일에서 확인할 수 있다.

/* src/main/java/com/anse/engineering/aspose/SearchDWGFileDir.java파일에 선언된 data directory를 가져오는 메서드 */
public static String getDataPath() {
        File dir = new File(System.getProperty("user.dir"));
        dir = new File(dir, "src");
        dir = new File(dir, "main");
        dir = new File(dir, "resources");
        return dir + File.separator;
}


private static final String dataDir = SearchDWGFileDir.getDataPath() + "DWGDrawings" + File.separator;

public static String getDataDir() {
    return dataDir;
}


public static Map<String, String> searchDWGFileInDataDir(String dirName) {
    Map<String, String> fileInfo = new HashMap<>();
    try {
        Files.walkFileTree(Paths.get(dataDir + dirName), new SimpleFileVisitor<>() {
            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
                if (!Files.isDirectory(file)) {
                    String fileName = file.getFileName().toString();
                    if (fileName.contains(".dwg")) {
                        String fileIndex = searchTextInDWGAutoCADFile(file.toAbsolutePath().toString());
                        fileInfo.put(fileName, fileIndex);
                    }
                }
                return FileVisitResult.CONTINUE;
            }
        });
    } catch (IOException e) {
        e.printStackTrace();
    }
    return fileInfo;
}


public static String searchTextInDWGAutoCADFile(String fileName) {
    String index = "";
    CadImage cadImage = (CadImage) CadImage.load(fileName);

    for (CadBlockEntity blockEntity : cadImage.getBlockEntities().getValues()) {
        for (CadBaseEntity entity : blockEntity.getEntities()) {
            if (entity.getTypeName() == CadEntityTypeName.TEXT) {
                CadText childObjectText = (CadText)entity;
                index += childObjectText.getDefaultValue();
                index += ", ";
            }
        }
    }
    return index;
}

이번에는 메서드 별로 설명을 해보자

가장 먼저 getDataDir() 메서드는 단순히 dataDir 멤버 변수에 저장된 값을 가져온다. dataDir 멤버 변수는 getDataPath() 메서드에 의해 초기화 되는데 초기화 할 때 운영체제에 따라 구분자가 달라 File.separator를 사용했다.

다음으로 볼 메서드는 searchTextInDWGAutoCADFile다. 이 메서드는 우선 CadImage.load(fileName) 메서드를 사용해 cadImage 인스턴스를 만든다. 이제 해당 파일에 대한 cadImage 인스턴스가 생긴 것이다. 그후 모든 blockEntities()에 포함된 값을 가져와 entity에서 텍스트 데이터를 추출하는데 이부분은 공식 docs에도 설명이 정확히 안 나와있어서 메서드 명을 보고 이해하는 수밖에 없다.

이번 게시물에서 가장 많이 고민했던 searchDWGFileInDataDir 메서드를 살펴보자

요청 받은 엑셀의 컬럼에 파일의 이름과 뽑아낸 텍스트 데이터가 필요했다. 이 때문에 Map을 사용했고 파일 이름을 key, 텍스트 데이터를 value로 설정했다.

고민의 대부분은 "어떻게 하면 폴더 내에 .dwg 확장자를 가진 모든 파일을 가져올 수 있을까" 였다.

처음에는 재귀함수와 java.io.File 클래스의 listFiles() 메서드를 조합해 가져온 리스트에서 디렉토리가 있다면 해당 디렉토리 이름으로 재귀함수를 다시 호출해 계속해서 디렉토리의 파일 리스트를 가져오는 DFS와 유사한 접근방법을 사용했다. 하지만 재귀함수를 쓰기 싫어서 다른 방법을 찾아보다 한 블로그에서 Walking이란 걸 알게 되었다.

Walking

파일을 나열하는 것 외에 directory의 더 깊은 레벨까지 순회하고 싶을 때 사용하는 walk() 메서드를 발견했다.

public Set<String> listFilesUsingFileWalk(String dir, int depth) throws IOException {
    try (Stream<Path> stream = Files.walk(Paths.get(dir), depth)) {
        return stream
          .filter(file -> !Files.isDirectory(file))
          .map(Path::getFileName)
          .map(Path::toString)
          .collect(Collectors.toSet());
    }
}

위처럼 사용하는데 파라미터로 전달된 directory level의 depth가 최대 깊이다. 이제 나는 굳이 재귀함수를 구현하지 않고도 파일 트리를 탐색해 모든 파일의 이름을 가져올 수 있게 되었다.

사실 모든 캐드(.dwg) 파일 이름을 가져오려면 walk()로 충분했지만 나는 이름을 가져올 뿐 아니라 이름을 사용해 텍스트를 추출하고 싶었다.

이럴 때 visitor를 제공하는 walkFileTree() 메서드를 사용할 수 있다.
첫번째 인자로 검색하려는 디렉토리 경로, 두번째 인자로 visitor를 사용하는데 visitor로는 검색된 파일을 처리하는 메서드를 정의해야 한다.

텍스트 추출은 파일만 해당하기 때문에 visitFile 메서드를 override해 파일에 접근했을 때 처리하는 메서드를 정의했다. 이밖에도 preVisitDirectory, postVisitDirectory, visitFileFailed가 있다. 이번에 사용한 메서드의 리턴 값으로는 FileVisitResult.CONTINUE를 주었는데 파일을 계속 탐색한다는 신호다.

메서드 본문에는 파일 이름을 가져오고, dwg파일이면 텍스트를 추출해서 fileInfo라는 Map에 담는다.


엑셀화

여기는 코드가 너무 못생겨서 마음의 준비를...

전체 코드

public static void main(String[] args) {
    XSSFWorkbook workbook = new XSSFWorkbook();
    XSSFSheet sheet = workbook.createSheet("engineerging data");
    Map<String, Object[]> data = new TreeMap<>();
    List<DwgFile> dwgFiles = findFileInfo();

    int tmp = 1, rowNum = 0, columnNum;

    data.put("1", new Object[] {"ID", "대분류", "제목", "인덱스"});
    for (DwgFile dwgFile: dwgFiles) {
        data.put(String.valueOf(++tmp), new Object[] {dwgFile.getId(), dwgFile.getMainCategory(),
                dwgFile.getFileName(), dwgFile.getIndex()});
    }
    Set<String> keyset = data.keySet();

    for (String key: keyset) {
        Row row = sheet.createRow(rowNum++);
        Object[] objArr = data.get(key);
        columnNum = 0;
        for (Object obj: objArr) {
            Cell column = row.createCell(columnNum++);
            if (obj instanceof String) {
                column.setCellValue((String)obj);
            } else if (obj instanceof Integer) {
                column.setCellValue((Integer)obj);
            }
        }
    }

    try (FileOutputStream out = new FileOutputStream(new File(filePath, fileName))) {
        workbook.write(out);
    } catch (IOException e) {
        e.printStackTrace();
    }
}

src/main/java/com/anse/engineering/poi/Poi.java 다시 여기로 돌아오자 지금까지 우리는 SearchTextInDWGFile 클래스의 getDataDir(), searchDWGFileInDataDir() 메서드를 사용해 캐드 파일이 저장된 디렉토리에서 모든 캐드 파일의 텍스트를 추출해
"캐드 파일 이름": "추출된 텍스트" key-value를 가진 Map 변수에 담았다.

이를 사용해

List<DwgFile> dwg = new ArrayList<>();

DwgFile List를 만들었다.

src/main/java/com/anse/engineering/dwgFile/DwgFile.java 파일에 간단하게 DwgFile class를 만들어주자

@Data
public class DwgFile {
    @Id
    private int id;
    private String mainCategory;
    private String fileName;
    private String index;
}

DwgFile() {};

public DwgFile(int id, String mainCategory, String fileName, String index) {
    this.id = id;
    this.mainCategory = mainCategory;
    this.fileName = fileName;
    this.index = index;
}

@Data 어노테이션

lombok이 제공하는 어노테이션으로 @ToString, @EqualAndHashCode, @Getter/@Setter, @RequiredArgsConstructor를 합친 단축어다.

POJOs(Plain Old Java Objects), bean과 관련된 모든 boilerplate(재사용 가능한 코드)를 생성한다. getter, setter 등의 메서드가 여기에 해당한다.

만들어진 List를 엑셀에 요청받은 형식대로 집어 넣어야 하는데 생각보다 되게 간단하다.

XSSFWorkbook 인스턴스를 하나 만들어주자 그러면 XSSF(*.xlsx 지원 포맷)의 Workbook(엑셀파일)하나가 생성된 것이다.

다음으로 workbook 엑셀 파일의 sheet를 생성해주고 "column": "채우고 싶은 데이터"의 key-value를 가진 Map 변수 data에 넣고싶은 값을 차례로 채워준다.

그 후 row를 만들어주고 cell(column)에 원하는 값을 채워준다.

결과


데모 시연이 코앞이라 테스트고 뭐고 best case만 되게 만들었는데 시연 끝나고 추가 개발이 시급하다!

프로젝트 깃허브

profile
Back-End Developer

0개의 댓글