diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt b/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt index fdc8b66..1a2109b 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt @@ -71,7 +71,7 @@ class SecurityConfig( csrf.ignoringRequestMatchers( "/user/login.bjx", "/user/joinUser.bjx","/tlg/repotToMe.bjx", "/blog/post/imageUpload.bjx", "/blog/post.bjx", - "/blog/post/images/**" + "/blog/post/images/**","/puzzle/upload.bjx" ) // 여기 예외 추가 }.authorizeHttpRequests { auth -> auth @@ -84,6 +84,7 @@ class SecurityConfig( "/blog/viewer/**" , "/blog/posts" , "/blog/rankOfViews.bjx","/blog/recentOfPost.bjx", // "/blog/post/imageUpload.bjx", "/blog/post/images/**", + "/puzzle/upload.bjx", "/css/**", "/js/**", "/images/**", "/webjars/**", "/assets/**").permitAll() .anyRequest().authenticated() }.formLogin { form -> diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt index 90e0f12..24a6670 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt @@ -151,6 +151,7 @@ class BlogController() { } } + @GetMapping("recentOfPost.bjx") fun recentOfPost(httpServletRequest: HttpServletRequest): Mono> { logService.log(httpServletRequest.requestURI) diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt new file mode 100644 index 0000000..b1c273e --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt @@ -0,0 +1,34 @@ +package kr.lunaticbum.back.lun.controllers + +import kr.lunaticbum.back.lun.model.PuzzleService +import kr.lunaticbum.back.lun.model.ResultMV +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile + +@RestController +@RequestMapping("/puzzle") +class PuzzleController(private val puzzleService: PuzzleService) { // 생성자 주입 + + @PostMapping("upload.bjx") + suspend fun createPuzzleFromImage(@RequestParam("imageFile") imageFile: MultipartFile): ResponseEntity { + return try { + val savedPuzzle = puzzleService.generateAndSavePuzzle(imageFile, 20) + ResponseEntity.ok(savedPuzzle) // 성공 시 200 OK와 함께 결과 반환 + } catch (e: Exception) { + e.printStackTrace() + // 실패 시 500 에러와 메시지 반환 + ResponseEntity.internalServerError().body("이미지 처리 중 오류 발생: ${e.message}") + } + } + + @GetMapping("/","/upload.bs") + suspend fun uploadPuzzle() : ResultMV { + val vm = ResultMV("content/puzzle/upload") + return vm + } +} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt new file mode 100644 index 0000000..ac960f9 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt @@ -0,0 +1,140 @@ +package kr.lunaticbum.back.lun.model + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.withContext +import kr.lunaticbum.back.lun.utils.ImageUtils.convertTransparentToWhite +import org.springframework.data.annotation.Id +import org.springframework.data.mongodb.core.mapping.Document +import org.springframework.data.mongodb.repository.ReactiveMongoRepository +import org.springframework.stereotype.Repository +import org.springframework.stereotype.Service +import org.springframework.web.multipart.MultipartFile +import java.awt.Color +import java.awt.image.BufferedImage +import java.time.LocalDateTime +import javax.imageio.ImageIO + + +@Document("puzzles") // "puzzles" 컬렉션에 매핑 +data class NonogramPuzzle( + @Id + val id: String? = null, // MongoDB가 생성하므로 nullable(null 가능) 및 var로 선언 + val solutionGrid: List>, + val rowClues: List>, + val colClues: List>, + val createdAt: LocalDateTime = LocalDateTime.now() // 생성 시점의 기본값 설정 +) + +@Repository +interface NonogramPuzzleRepository : ReactiveMongoRepository { + // ReactiveMongoRepository가 모든 기본 CRUD 기능을 반응형으로 제공 +} + +@Service +class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { // 생성자 주입 + + // suspend 함수로 비동기 작업을 선언 + suspend fun generateAndSavePuzzle(file: MultipartFile, size: Int): NonogramPuzzle { + val puzzleData = withContext(Dispatchers.IO) { + val originalImage = ImageIO.read(file.inputStream) + + // 1. (★중요) 투명 배경을 흰색으로 먼저 변환합니다. + val imageWithBackground = convertTransparentToWhite(originalImage) + + // 2. 그레이스케일 변환 (흰색 배경이 적용된 이미지를 사용) + val grayImage = BufferedImage(size, size, BufferedImage.TYPE_BYTE_GRAY).apply { + createGraphics().run { + drawImage(imageWithBackground, 0, 0, size, size, null) + dispose() + } + } + + // 3. 평균 밝기를 계산하여 동적 임계치 결정 + val averageBrightness = calculateAverageBrightness(grayImage) + val adaptiveThreshold = determineAdaptiveThreshold(averageBrightness) + + // 4. 결정된 임계치로 최종 그리드 생성 + val solutionGrid = List(size) { y -> + List(size) { x -> + if (grayImage.raster.getSample(x, y, 0) < adaptiveThreshold) 1 else 0 + } + } + + // 5. 힌트 추출 및 객체 생성 + val rowClues = solutionGrid.map { getCluesForLine(it) } + val colClues = transpose(solutionGrid).map { getCluesForLine(it) } + + NonogramPuzzle(solutionGrid = solutionGrid, rowClues = rowClues, colClues = colClues) + } + + return puzzleRepository.save(puzzleData).awaitSingle() + } + + /** + * (★추가된 함수) 투명한 배경을 가진 BufferedImage를 흰색 배경으로 변환합니다. + */ + private fun convertTransparentToWhite(sourceImage: BufferedImage): BufferedImage { + if (!sourceImage.colorModel.hasAlpha()) { + return sourceImage + } + + return BufferedImage(sourceImage.width, sourceImage.height, BufferedImage.TYPE_INT_RGB).apply { + createGraphics().also { g2d -> + g2d.color = Color.WHITE + g2d.fillRect(0, 0, width, height) + g2d.drawImage(sourceImage, 0, 0, null) + g2d.dispose() + } + } + } + + /** + * 그레이스케일 이미지의 평균 밝기를 계산합니다. (0~255) + */ + private fun calculateAverageBrightness(image: BufferedImage): Int { + var totalBrightness: Long = 0 + val width = image.width + val height = image.height + for (y in 0 until height) { + for (x in 0 until width) { + totalBrightness += image.raster.getSample(x, y, 0) + } + } + return (totalBrightness / (width * height)).toInt() + } + + /** + * 평균 밝기에 따라 임계치를 조정합니다. + */ + private fun determineAdaptiveThreshold(averageBrightness: Int): Int { + return when { + // 이미지가 매우 밝으면 (평균 180 이상), 임계치를 평균보다 약간 낮춰 어두운 부분을 더 잘 잡아냄 + averageBrightness > 180 -> (averageBrightness * 0.9).toInt() + // 이미지가 매우 어두우면 (평균 80 이하), 임계치를 평균보다 약간 높여 밝은 부분을 더 잘 잡아냄 + averageBrightness < 80 -> (averageBrightness * 1.1).toInt() + // 보통 밝기의 이미지면 평균값을 그대로 사용 + else -> averageBrightness + } + } + + // --- 헬퍼 함수들 --- + private fun getCluesForLine(line: List): List { + val clues = mutableListOf() + var count = 0 + for (cell in line) { + if (cell == 1) { + count++ + } else if (count > 0) { + clues.add(count) + count = 0 + } + } + if (count > 0) clues.add(count) + return if (clues.isEmpty()) listOf(0) else clues + } + + private fun transpose(grid: List>): List> { + return List(grid[0].size) { j -> List(grid.size) { i -> grid[i][j] } } + } +} diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/utils/ImageUtils.kt b/src/main/kotlin/kr/lunaticbum/back/lun/utils/ImageUtils.kt new file mode 100644 index 0000000..1f31a87 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/utils/ImageUtils.kt @@ -0,0 +1,43 @@ +package kr.lunaticbum.back.lun.utils + +import java.awt.Color +import java.awt.image.BufferedImage + + +object ImageUtils { + /** + * 투명한 배경을 가진 BufferedImage를 흰색 배경으로 변환합니다. + * @param sourceImage 원본 이미지 + * @return 흰색 배경이 적용된 새 BufferedImage + */ + fun convertTransparentToWhite(sourceImage: BufferedImage): BufferedImage { + // 원본 이미지에 알파 채널이 없으면 그대로 반환 + if (!sourceImage.getColorModel().hasAlpha()) { + return sourceImage + } + + // 원본과 같은 크기, 하지만 알파 채널이 없는(RGB) 새 이미지를 생성 + val newImage = BufferedImage( + sourceImage.getWidth(), + sourceImage.getHeight(), + BufferedImage.TYPE_INT_RGB // 알파 채널이 없는 타입 + ) + + // 새 이미지의 Graphics2D 컨텍스트를 얻음 (그림을 그릴 도구) + val g2d = newImage.createGraphics() + + try { + // 1. 새 이미지의 배경을 흰색으로 완전히 칠합니다. + g2d.setColor(Color.WHITE) + g2d.fillRect(0, 0, newImage.getWidth(), newImage.getHeight()) + + // 2. 흰색 배경 위에 원본 이미지를 그립니다. (투명한 부분은 흰색 배경이 비침) + g2d.drawImage(sourceImage, 0, 0, null) + } finally { + // 리소스를 해제합니다. + g2d.dispose() + } + + return newImage + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 1e50057..486b12f 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -96,3 +96,7 @@ spring.jpa.properties.hibernate.format_sql=true logging.level.org.hibernate.SQL=DEBUG logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE +# Increase server connection timeout to 60 seconds (default is often 20 or 30s) +server.tomcat.connection-timeout=60s +# For reactive applications (like yours), also set this timeout +spring.webflux.response-timeout=60s \ No newline at end of file diff --git a/src/main/resources/static/css/puzzle.css b/src/main/resources/static/css/puzzle.css new file mode 100644 index 0000000..1ccfce8 --- /dev/null +++ b/src/main/resources/static/css/puzzle.css @@ -0,0 +1,41 @@ +#puzzle-container { + display: grid; + /* We will set grid-template-columns/rows with JS */ + grid-gap: 2px; + margin-top: 20px; + background-color: #333; + border: 2px solid #333; + width: fit-content; +} + +.grid-cell { + width: 25px; + height: 25px; + background-color: #f0f0f0; + text-align: center; + line-height: 25px; + font-size: 14px; +} + +.clue-cell { + background-color: #cce7ff; + display: flex; + justify-content: center; + align-items: center; + padding: 5px; + min-height: 25px; + font-weight: bold; +} + +.solution-cell { + width: 25px; + height: 25px; +} + +.filled { + background-color: #333; +} + +.empty { + background-color: #fff; +} \ No newline at end of file diff --git a/src/main/resources/static/js/upload.js b/src/main/resources/static/js/upload.js new file mode 100644 index 0000000..7f6e32a --- /dev/null +++ b/src/main/resources/static/js/upload.js @@ -0,0 +1,88 @@ +function drawPuzzle(puzzleData) { + const container = document.getElementById('puzzle-container'); + container.innerHTML = ''; // Clear previous puzzle + + const { solutionGrid, rowClues, colClues } = puzzleData; + const numRows = solutionGrid.length; + const numCols = solutionGrid[0].length; + + // Set up the CSS Grid layout + container.style.gridTemplateColumns = `auto repeat(${numCols}, 1fr)`; + container.style.gridTemplateRows = `auto repeat(${numRows}, 1fr)`; + + // 1. Create top-left empty corner + const corner = document.createElement('div'); + corner.className = 'grid-cell'; + container.appendChild(corner); + + // 2. Create column clues (top row) + for (const clues of colClues) { + const clueCell = document.createElement('div'); + clueCell.className = 'clue-cell'; + clueCell.innerHTML = clues.join('
'); // Display clues vertically + container.appendChild(clueCell); + } + + // 3. Create row clues and the solution grid + for (let i = 0; i < numRows; i++) { + // Create row clue cell for this row + const rowClueCell = document.createElement('div'); + rowClueCell.className = 'clue-cell'; + rowClueCell.textContent = rowClues[i].join(' '); + container.appendChild(rowClueCell); + + // Create the solution cells for this row + for (let j = 0; j < numCols; j++) { + const cell = document.createElement('div'); + cell.className = 'solution-cell'; + if (solutionGrid[i][j] === 1) { + cell.classList.add('filled'); // Black square + } else { + cell.classList.add('empty'); // White square + } + container.appendChild(cell); + } + } +} +// Modify your existing click listener +document.addEventListener('DOMContentLoaded', () => { + document.getElementById('createBtn').addEventListener('click', async () => { + const uploader = document.getElementById('imageUploader'); + const statusDiv = document.getElementById('status'); + const puzzleContainer = document.getElementById('puzzle-container'); + + if (uploader.files.length === 0) { + statusDiv.textContent = '이미지 파일을 선택해주세요.'; + return; + } + + const imageFile = uploader.files[0]; + const formData = new FormData(); + formData.append('imageFile', imageFile); + + statusDiv.textContent = '문제를 생성하는 중...'; + puzzleContainer.innerHTML = ''; // Clear old puzzle while loading + + try { + const response = await fetch(getMainPath()+'/puzzle/upload.bjx', { // Make sure this URL is correct + method: 'POST', + body: formData, + }); + + if (response.ok) { + const puzzleData = await response.json(); + statusDiv.textContent = '문제 생성 성공!'; + + // Call the new function to draw the puzzle! + drawPuzzle(puzzleData); + + } else { + const errorMessage = await response.text(); + statusDiv.textContent = `생성 실패: ${errorMessage}`; + } + } catch (error) { + console.error('네트워크 오류:', error); + statusDiv.textContent = '서버와 통신 중 오류가 발생했습니다.'; + } + }); +}); \ No newline at end of file diff --git a/src/main/resources/templates/content/puzzle/upload.html b/src/main/resources/templates/content/puzzle/upload.html new file mode 100644 index 0000000..02a6716 --- /dev/null +++ b/src/main/resources/templates/content/puzzle/upload.html @@ -0,0 +1,30 @@ + + + + + + + + + +
+
+ +
+

이미지로 네모로직 문제 만들기

+ + +
+
+

결과

+
+ +
+