[1470] 배치도·계통도 및 구조도의 화질 개선 cell 이미지 추가

This commit is contained in:
ysCha 2026-03-11 18:19:35 +09:00
parent 07ce6b6f4d
commit be95b1b39b
2 changed files with 255 additions and 142 deletions

View File

@ -3,6 +3,37 @@ import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } fro
import { v4 as uuidv4 } from 'uuid'
import { Jimp } from 'jimp'
/**
* ======================================================================
* 캔버스 도면 이미지 처리 API
* ======================================================================
*
* [개요]
* Fabric.js 캔버스에서 캡처한 도면 이미지를 2벌로 가공하여 S3에 업로드한다.
*
* [생성되는 이미지 2]
* 1. 셀범위용 (기존 이미지명 유지)
* - 파일명: {objectNo}_{planNo}_{type}.png
* - 용도: 割付図系統図, 架台図 시트 (jx:image lastCell 셀범위에 맞춤)
* - 비율: 엑셀 영역 비율 35.4:12.89 (~2.75:1) 맞춰 여백 추가
* - 템플릿: data.drawingImg1, data.drawingImg2
*
* 2. 풀사이즈용 (신규)
* - 파일명: {objectNo}_{planNo}_{type}_full.png
* - 용도: P1, P2 시트 (A4 가로 전체 페이지에 이미지만 출력)
* - 비율: A4 가로 비율 27.7:19.0 (~1.46:1) 맞춰 여백 최소화
* - 템플릿: data.drawingImg1Full, data.drawingImg2Full
*
* [처리 흐름]
* 프론트(useImgLoader) 도면 영역만 캡처하여 PNG 전송
* S3에 원본 임시 저장 (uuid )
* autocrop으로 여백 제거
* 풀사이즈/셀범위 2 리사이즈
* S3에 2 업로드
* 원본 삭제
* ======================================================================
*/
const Bucket = process.env.AMPLIFY_BUCKET
const s3 = new S3Client({
region: process.env.AWS_REGION,
@ -12,113 +43,140 @@ const s3 = new S3Client({
},
})
const checkArea = (obj) => {
const { width, height, left, top } = obj
if (left < 0 || top < 0 || width > 1600 || height > 1000) {
return false
/**
* S3에서 이미지를 읽어 autocrop된 Jimp 이미지를 반환한다.
* 프론트에서 배경을 흰색으로 설정하여 캡처하므로 autocrop이 정상 동작한다.
*
* @param {string} Key - S3 오브젝트
* @returns {Promise<Jimp>} autocrop된 Jimp 이미지 객체
*/
const loadAndCrop = async (Key) => {
const { Body } = await s3.send(
new GetObjectCommand({ Bucket, Key }),
)
const chunks = []
for await (const chunk of Body) {
chunks.push(chunk)
}
const buffer = Buffer.concat(chunks)
const image = await Jimp.read(buffer)
return true
// tolerance: 배경색과 5% 차이까지 여백으로 인식
// leaveBorder: 크롭 후 5px 테두리 여유
image.autocrop({ tolerance: 0.05, leaveBorder: 5 })
return image
}
const cropImage = async (Key, width, height, left, top) => {
/**
* 풀사이즈용 리사이즈 (P1, P2 시트)
*
* A4 가로(297mm × 210mm) 인쇄 영역에 맞춰 이미지를 리사이즈한다.
* - 가로 도면: A4에 거의
* - 세로 도면: 상하가 차고 좌우에 최소 여백
*
* 엑셀 템플릿 설정: jx:image(lastCell="V47" src="data.drawingImg1Full" imageType="PNG")
*
* @param {Jimp} image - autocrop된 Jimp 이미지 객체
* @returns {Promise<Jimp>} A4 비율에 맞춘 이미지
*/
const resizeForFull = async (image) => {
const DPI = 200
// A4 가로 인쇄 영역 (여백 제외): 약 277mm × 190mm
const maxWidth = Math.round((27.7 * DPI) / 2.54) // ~2181px
const maxHeight = Math.round((19.0 * DPI) / 2.54) // ~1496px
// 이미지를 인쇄 영역의 95%까지 채움 (약간의 여유)
const targetW = maxWidth * 0.95
const targetH = maxHeight * 0.95
const scaleX = targetW / image.bitmap.width
const scaleY = targetH / image.bitmap.height
const scale = Math.min(scaleX, scaleY) // 비율 유지하며 최대 크기
image.resize({ w: Math.round(image.bitmap.width * scale), h: Math.round(image.bitmap.height * scale) })
// A4 비율의 흰 배경 캔버스에 이미지를 중앙 배치
const background = new Jimp({ width: maxWidth, height: maxHeight, color: 0xffffffff })
const x = Math.floor((maxWidth - image.bitmap.width) / 2)
const y = Math.floor((maxHeight - image.bitmap.height) / 2)
background.composite(image, x, y)
return background
}
/**
* 셀범위용 리사이즈 (割付図系統図, 架台図 시트)
*
* 엑셀 템플릿의 이미지 영역(35.4cm × 12.89cm) 비율에 맞춰 리사이즈한다.
* 가로로 넓은 비율(~2.75:1)이므로 세로 도면은 좌우 여백이 많아진다.
*
* 엑셀 템플릿 설정: jx:image(lastCell="V38" src="data.drawingImg1" imageType="PNG")
*
* @param {Jimp} image - autocrop된 Jimp 이미지 객체
* @returns {Promise<Jimp>} 비율에 맞춘 이미지
*/
const resizeForCell = async (image) => {
const DPI = 200
// 엑셀 셀 영역 크기: 35.4cm × 12.89cm
const maxWidth = Math.round((35.4 * DPI) / 2.54) // ~2787px
const maxHeight = Math.round((12.89 * DPI) / 2.54) // ~1015px
// 이미지를 셀 영역의 98%까지 채움
const targetW = maxWidth * 0.98
const targetH = maxHeight * 0.98
const scaleX = targetW / image.bitmap.width
const scaleY = targetH / image.bitmap.height
const scale = Math.min(scaleX, scaleY) // 비율 유지하며 최대 크기
image.resize({ w: Math.round(image.bitmap.width * scale), h: Math.round(image.bitmap.height * scale) })
// 셀 비율의 흰 배경 캔버스에 이미지를 중앙 배치
const background = new Jimp({ width: maxWidth, height: maxHeight, color: 0xffffffff })
const x = Math.floor((maxWidth - image.bitmap.width) / 2)
const y = Math.floor((maxHeight - image.bitmap.height) / 2)
background.composite(image, x, y)
return background
}
/**
* 이미지 2 생성: 풀사이즈(P1,P2) + 셀범위(割付図 )
*
* @param {string} Key - S3에 저장된 원본 이미지
* @returns {Promise<{fullBuffer: Buffer, cellBuffer: Buffer}>} 2벌의 PNG 버퍼
*/
const processImage = async (Key) => {
try {
// Get the image from S3
const { Body } = await s3.send(
new GetObjectCommand({
Bucket,
Key,
}),
)
const image = await loadAndCrop(Key)
const imageCopy = image.clone() // 동일 원본에서 2벌 생성을 위해 복제
const chunks = []
for await (const chunk of Body) {
chunks.push(chunk)
}
const buffer = Buffer.concat(chunks)
const fullBuffer = await resizeForFull(image).then((img) => img.getBuffer('image/png'))
const cellBuffer = await resizeForCell(imageCopy).then((img) => img.getBuffer('image/png'))
let image = await Jimp.read(buffer)
image.autocrop({ tolerance: 0.0002, leaveBorder: 10 })
const resizedImage = await resizeImage(image).then((result) => {
return result
})
return await resizedImage.getBuffer('image/png')
// Convert stream to buffer
// const chunks = []
// for await (const chunk of Body) {
// chunks.push(chunk)
// }
// const imageBuffer = Buffer.concat(chunks)
// const image = await Jimp.read(Body)
// if (!checkResult) {
// processedImage = await image.toBuffer()
// }
//let processedImage
// if (!checkResult) {
// processedImage = await sharp(imageBuffer).toBuffer()
// } else {
// processedImage = await sharp(imageBuffer)
// .extract({
// width: parseInt(width),
// height: parseInt(height),
// left: parseInt(left),
// top: parseInt(top),
// })
// .png()
// .toBuffer()
// }
// return processedImage
return { fullBuffer, cellBuffer }
} catch (error) {
console.error('Error processing image:', error)
throw error
}
}
//크롭된 이미지를 배경 크기에 맞게 리사이즈
const resizeImage = async (image) => {
//엑셀 템플릿 너비 35.4cm, 높이 12.89cm
const convertStandardWidth = Math.round((35.4 * 96) / 2.54)
const convertStandardHeight = Math.round((12.89 * 96) / 2.54)
// 이미지를 배경의 98%까지 확대 (훨씬 더 크게)
const targetImageWidth = convertStandardWidth * 0.98
const targetImageHeight = convertStandardHeight * 0.98
const scaleX = targetImageWidth / image.bitmap.width
const scaleY = targetImageHeight / image.bitmap.height
let scale = Math.min(scaleX, scaleY) // 비율 유지하면서 최대한 크게
let finalWidth = Math.round(image.bitmap.width * scale)
let finalHeight = Math.round(image.bitmap.height * scale)
// 항상 리사이즈 실행 (scale >= 0.6 조건 제거)
image.resize({ w: finalWidth, h: finalHeight })
//배경 이미지를 생성
const mixedImage = new Jimp({ width: convertStandardWidth, height: convertStandardHeight, color: 0xffffffff })
//이미지를 중앙에 배치
const x = Math.floor((mixedImage.bitmap.width - image.bitmap.width) / 2)
const y = Math.floor((mixedImage.bitmap.height - image.bitmap.height) / 2)
//이미지를 배경 이미지에 합성
mixedImage.composite(image, x, y, {
opacitySource: 1, // 원본 투명도 유지
opacityDest: 1,
})
// 1.5x 확대 로직 제거 - 이미지가 템플릿 크기를 초과하지 않도록 함
return mixedImage
}
/**
* POST: 캔버스 도면 이미지 처리 S3 업로드
*
* [요청 FormData]
* - file: PNG 이미지 (프론트에서 도면 영역만 캡처)
* - objectNo: 물건번호
* - planNo: 프랜번호
* - type: 1(모듈만) / 2(가대포함)
* - width, height, left, top: 크롭 좌표 (프론트에서 이미 크롭하여 전송하므로 left=0, top=0)
*
* [응답 JSON]
* - filePath: 셀범위용 이미지 URL (기존 이미지명)
* - fullFilePath: 풀사이즈용 이미지 URL (_full 접미사)
* - fileName: 셀범위용 S3
* - fullFileName: 풀사이즈용 S3
*/
export async function POST(req) {
try {
const formData = await req.formData()
@ -126,16 +184,12 @@ export async function POST(req) {
const objectNo = formData.get('objectNo')
const planNo = formData.get('planNo')
const type = formData.get('type')
const width = formData.get('width')
const height = formData.get('height')
const left = formData.get('left')
const top = formData.get('top')
const OriginalKey = `Drawing/${uuidv4()}`
/**
* 원본 이미지를 우선 저장한다.
* 이미지 이름이 겹지는 현상을 방지하기 위해 uuid 사용한다.
* 1단계: 원본 이미지를 S3에 임시 저장
* - uuid를 사용하여 이미지 이름 충돌 방지
*/
await s3.send(
new PutObjectCommand({
@ -147,29 +201,26 @@ export async function POST(req) {
)
/**
* 저장된 원본 이미지를 기준으로 크롭여부를 결정하여 크롭 이미지를 저장한다.
* 2단계: 이미지 2 생성
* - fullBuffer: P1, P2 시트용 (A4 가로 비율, 여백 최소화)
* - cellBuffer: 割付図架台図 시트용 (셀범위 비율, 여백 포함)
*/
const bufferImage = await cropImage(OriginalKey, width, height, left, top)
const { fullBuffer, cellBuffer } = await processImage(OriginalKey)
// 기존 이미지명 유지 (셀범위용), _full 접미사 추가 (풀사이즈용)
const cellKey = `Drawing/${process.env.S3_PROFILE}/${objectNo}_${planNo}_${type}.png`
const fullKey = `Drawing/${process.env.S3_PROFILE}/${objectNo}_${planNo}_${type}_full.png`
/**
* 크롭 이미지 이름을 결정한다.
* 3단계: 2 이미지를 S3에 병렬 업로드
*/
const Key = `Drawing/${process.env.S3_PROFILE}/${objectNo}_${planNo}_${type}.png`
await Promise.all([
s3.send(new PutObjectCommand({ Bucket, Key: fullKey, Body: fullBuffer, ContentType: 'image/png' })),
s3.send(new PutObjectCommand({ Bucket, Key: cellKey, Body: cellBuffer, ContentType: 'image/png' })),
])
/**
* 크롭이 완료된 이미지를 업로드한다.
*/
await s3.send(
new PutObjectCommand({
Bucket,
Key,
Body: bufferImage,
ContentType: 'image/png',
}),
)
/**
* 크롭이미지 저장이 완료되면 원본 이미지를 삭제한다.
* 4단계: 원본 임시 이미지 삭제
*/
await s3.send(
new DeleteObjectCommand({
@ -179,8 +230,10 @@ export async function POST(req) {
)
const result = {
filePath: `https://${process.env.AMPLIFY_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${Key}`,
fileName: Key,
filePath: `https://${process.env.AMPLIFY_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${cellKey}`,
fullFilePath: `https://${process.env.AMPLIFY_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${fullKey}`,
fileName: cellKey,
fullFileName: fullKey,
}
return NextResponse.json(result)

View File

@ -8,30 +8,53 @@ import { useContext } from 'react'
import Config from '@/config/config.export'
/**
* 이미지 로더 hook
* 캔버스를 바이너리로 변환하고 이미지 객체에 붙여서 다시 이미지를 바이너리로 전달
* 캔버스 데이터가 바이너리로 변경되면 용량이 너무 커서 post전송에 실패할 있음
* @returns {function} handleCanvasToPng
* ======================================================================
* 이미지 로더 Hook
* ======================================================================
*
* [개요]
* Fabric.js 캔버스에서 도면 영역만 PNG로 캡처하여 서버(/api/image/canvas) .
* 서버에서 2(풀사이즈/셀범위) 가공 S3에 업로드한다.
*
* [캡처 방식]
* - getBoundingRect() 도면 오브젝트(지붕, 치수, 화살표) 정확한 바운딩 박스 계산
* - canvas.toDataURL() 크롭 좌표를 전달하여 도면 영역만 직접 캡처
* - 배경을 흰색으로 설정하여 서버 autocrop이 정상 동작하도록
* - multiplier: 2 고해상도(2) 캡처
*
* @returns {function} handleCanvasToPng - 캔버스를 PNG로 캡처하여 서버에 전송
*/
export function useImgLoader() {
const canvas = useRecoilValue(canvasState)
const { currentCanvasPlan } = usePlan()
const { post } = useAxios()
const { setIsGlobalLoading } = useContext(QcastContext)
/**
* 이미지 저장 왼쪽 , 오른쪽 아래 좌표
* return [start, end]
* 도면 오브젝트의 바운딩 박스를 계산한다.
* 지붕(ROOF), 치수 텍스트(lengthText), 방위 화살표(arrow) 대상으로 한다.
* getBoundingRect() 사용하여 스케일/회전이 적용된 정확한 좌표를 구한다.
*
* @returns {Array<{x: number, y: number}>} [좌상단 좌표, 우하단 좌표] (margin 포함)
*/
const getImageCoordinates = () => {
const margin = 20
const objects = canvas.getObjects().filter((obj) => [POLYGON_TYPE.ROOF, 'lengthText', 'arrow'].includes(obj.name))
const minX = objects.reduce((acc, cur) => (cur.left < acc ? cur.left : acc), objects[0].left)
const minY = objects.reduce((acc, cur) => (cur.top < acc ? cur.top : acc), objects[0].top)
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
objects.forEach((obj) => {
const rect = obj.getBoundingRect()
minX = Math.min(minX, rect.left)
minY = Math.min(minY, rect.top)
maxX = Math.max(maxX, rect.left + rect.width)
maxY = Math.max(maxY, rect.top + rect.height)
})
const maxX = objects.reduce((acc, cur) => (cur.left + cur.width > acc ? cur.left : acc), 0)
const maxY = objects.reduce((acc, cur) => (cur.top + cur.height > acc ? cur.top : acc), 0)
return [
{ x: minX - margin, y: minY - margin },
{ x: maxX + margin, y: maxY + margin },
@ -39,13 +62,29 @@ export function useImgLoader() {
}
/**
* 캔버스를 이미지로 저장
* @param {integer} type 1: 모듈만 있는 상태, 2: 가대까지 올린 상태
* 캔버스를 PNG 이미지로 캡처하여 서버에 전송한다.
*
* [처리 순서]
* 1. 그리드/흡착점 불필요한 요소 숨김
* 2. 배경을 흰색으로 변경 (격자 패턴 제거 autocrop 가능하도록)
* 3. 도면 오브젝트의 바운딩 박스 계산
* 4. 해당 영역만 2 해상도로 캡처 (toDataURL)
* 5. FormData로 서버 API에 전송
* 6. 배경/그리드 원복
*
* @param {number} type - 1: 모듈만 있는 상태, 2: 가대까지 올린 상태
* @returns {Promise<Object>} 서버 응답 (filePath, fullFilePath, fileName, fullFileName)
*/
const handleCanvasToPng = async (type) => {
try {
// 1. 그리드, 흡착점, 마우스라인 등 제거/숨김
toggleLineEtc(false)
// 2. 배경을 흰색으로 설정 (격자 패턴이 autocrop을 방해하므로)
const originalBg = canvas.backgroundColor
canvas.backgroundColor = '#ffffff'
// CORS 대응: 이미지 오브젝트에 crossOrigin 설정
canvas.getObjects('image').forEach((obj) => {
if (obj.getSrc) {
const img = new Image()
@ -58,51 +97,72 @@ export function useImgLoader() {
canvas.renderAll()
const formData = new FormData()
// 고해상도 캡처를 위해 multiplier 옵션 추가 (2배 해상도)
// 3. 도면 오브젝트의 바운딩 박스 계산
const positionObj = getImageCoordinates()
console.log('🚀 ~ handleCanvasToPng ~ positionObj:', positionObj)
const cropLeft = positionObj[0].x
const cropTop = positionObj[0].y
const cropWidth = positionObj[1].x - positionObj[0].x
const cropHeight = positionObj[1].y - positionObj[0].y
// 4. 도면 영역만 2배 해상도로 캡처
// toDataURL에 left/top/width/height를 전달하면 해당 영역만 캡처됨
const multiplier = 2
const dataUrl = canvas.toDataURL({
format: 'png',
multiplier: multiplier,
left: cropLeft,
top: cropTop,
width: cropWidth,
height: cropHeight,
})
// base64 → Blob 변환
const blobBin = atob(dataUrl.split(',')[1])
const array = []
for (let i = 0; i < blobBin.length; i++) {
array.push(blobBin.charCodeAt(i))
}
const file = new Blob([new Uint8Array(array)], { type: 'image/png' })
// 5. FormData 구성 및 서버 전송
formData.append('file', file, 'canvas.png')
formData.append('objectNo', currentCanvasPlan.objectNo)
formData.append('planNo', currentCanvasPlan.planNo)
formData.append('type', type)
/** 이미지 크롭 좌표 계산 (multiplier 배율 적용) */
const positionObj = getImageCoordinates()
console.log('🚀 ~ handleCanvasToPng ~ positionObj:', positionObj)
formData.append('width', Math.round((positionObj[1].x - positionObj[0].x + 100) * multiplier))
formData.append('height', Math.round((positionObj[1].y - positionObj[0].y + 100) * multiplier))
formData.append('left', Math.round(positionObj[0].x * multiplier))
formData.append('top', Math.round(positionObj[0].y * multiplier))
formData.append('width', Math.round(cropWidth * multiplier))
formData.append('height', Math.round(cropHeight * multiplier))
formData.append('left', 0) // 프론트에서 이미 크롭하여 전송하므로 0
formData.append('top', 0)
console.log('🚀 ~ handleCanvasToPng ~ formData:', formData)
/** 이미지 크롭 요청 */
const result = await post({
// url: `${process.env.NEXT_PUBLIC_API_HOST_URL}/image/canvas`,
url: `${Config().baseUrl}/api/image/canvas`,
data: formData,
})
console.log('🚀 ~ handleCanvasToPng ~ result:', result)
// 6. 배경/그리드 원복
canvas.backgroundColor = originalBg
canvas.renderAll()
toggleLineEtc(true)
return result
} catch (e) {
canvas.backgroundColor = originalBg
canvas.renderAll()
setIsGlobalLoading(false)
console.log('🚀 ~ handleCanvasToPng ~ e:', e)
}
}
/**
* 마우스 포인터 그리드, 임의그리드, 흡착점 제거.
* 캡처 불필요한 요소(마우스라인, 그리드, 흡착점) 숨기거나 복원한다.
*
* @param {boolean} visible - true: 복원, false: 숨김
*/
const toggleLineEtc = (visible = false) => {
if (canvas?._objects.length > 0) {