// // DiaryTemplates.swift // Haru_Danim // // 일기 노트 페이지 양식(속지) — 외부 PDF/사진을 배경 양식으로 가져와 재사용한다 (CLAUDE.md §6.7). // // 구조: // - DiaryTemplateRenderer: PDF 쪽을 페이지 논리 크기(768×1024)에 aspect-fit으로 래스터화. // PDF는 벡터 원본을 보존하고 "현재 줌 배율"로 그때그때 다시 그려 확대해도 선명하다. // 이미지는 가져올 때 필기 래스터 상한(3×)과 같은 해상도로 리샘플 — 필기보다 흐려질 수 없다. // - DiaryTemplateBackground: 노트 페이지의 필기 아래에 깔리는 배경 뷰. // DiaryZoomScrollView의 선명도 훅(assignCanvasScale)이 펜슬 캔버스와 함께 배율을 지정한다. // - DiaryTemplateManagerView: 달력 화면에서 여는 양식 라이브러리 (가져오기·이름 변경·삭제). // - DiaryPageChooserSheet: 페이지 추가 시 [빈 캔버스 / 줄 노트 / 내 양식] 선택. // 여러 쪽 PDF 양식은 2단계로 쪽 썸네일을 펼쳐 원하는 쪽을 고른다. // // 양식 삭제 시 그 양식을 쓰던 페이지는 빈 캔버스로 폴백한다 (필기·요소는 그대로). // import SwiftUI import SwiftData import PhotosUI import UniformTypeIdentifiers // MARK: - 렌더러 enum DiaryTemplateRenderer { /// PDF 가져오기 상한 (스캔본 등 거대 파일 방어 — CloudKit 동기화 부담 방지) static let maxPDFBytes = 20 * 1024 * 1024 /// 사용 쪽 수 상한 (전자책 같은 오용 방지 — 초과분은 잘라내고 안내) static let maxPages = 24 /// 이미지 양식 저장 해상도 상한 (긴 변 px) — 필기 래스터 상한(기기 스케일×3)과 같은 기준 static let maxImageSide: CGFloat = 3072 /// PDF 쪽 수 (열 수 없으면 nil) static func pdfPageCount(_ data: Data) -> Int? { guard let provider = CGDataProvider(data: data as CFData), let document = CGPDFDocument(provider) else { return nil } return document.numberOfPages } /// PDF 한 쪽을 페이지 논리 크기(768×1024)에 aspect-fit으로 래스터화. /// 비율이 다른 PDF(A4 등)는 여백을 두고 가운데 맞춤 — 페이지 좌표계(필기·내보내기)는 불변. /// scale = 포인트당 픽셀 배율 (기기 스케일 × 줌, 상한은 호출부가 관리) static func renderPDFPage(data: Data, pageIndex: Int, scale: CGFloat) -> UIImage? { guard scale > 0, let provider = CGDataProvider(data: data as CFData), let document = CGPDFDocument(provider), let page = document.page(at: pageIndex + 1) else { return nil } let pageSize = DiaryPageMetrics.size let box = page.getBoxRect(.cropBox) guard box.width > 0, box.height > 0 else { return nil } let fitScale = min(pageSize.width / box.width, pageSize.height / box.height) let drawSize = CGSize(width: box.width * fitScale, height: box.height * fitScale) let origin = CGPoint(x: (pageSize.width - drawSize.width) / 2, y: (pageSize.height - drawSize.height) / 2) let format = UIGraphicsImageRendererFormat() format.scale = scale format.opaque = false return UIGraphicsImageRenderer(size: pageSize, format: format).image { ctx in let cg = ctx.cgContext // PDF 콘텐츠는 배경이 투명한 경우가 많아 '종이' 느낌의 흰 바탕을 깐다 (다크 모드 포함) cg.setFillColor(UIColor.white.cgColor) cg.fill(CGRect(origin: origin, size: drawSize)) cg.saveGState() cg.translateBy(x: origin.x, y: origin.y + drawSize.height) cg.scaleBy(x: fitScale, y: -fitScale) cg.translateBy(x: -box.minX, y: -box.minY) cg.interpolationQuality = .high cg.drawPDFPage(page) cg.restoreGState() } } /// 양식 한 쪽의 표시용 이미지 (내보내기 등 단발 렌더용) static func image(for template: DiaryTemplate, pageIndex: Int, scale: CGFloat) -> UIImage? { switch template.kind { case .pdf: return renderPDFPage(data: template.data, pageIndex: pageIndex, scale: scale) case .image: return UIImage(data: template.data) } } /// 가져온 사진을 저장용으로 리샘플 (상한 해상도 = 필기 선명도 상한과 동일 → 체감 흐림 없음) static func normalizedImageData(_ image: UIImage) -> Data? { let pixelWidth = image.size.width * image.scale let pixelHeight = image.size.height * image.scale let longest = max(pixelWidth, pixelHeight) guard longest > 0 else { return nil } let ratio = longest > maxImageSide ? maxImageSide / longest : 1 let target = CGSize(width: pixelWidth * ratio, height: pixelHeight * ratio) let format = UIGraphicsImageRendererFormat() format.scale = 1 let resized = UIGraphicsImageRenderer(size: target, format: format).image { _ in image.draw(in: CGRect(origin: .zero, size: target)) } return resized.jpegData(compressionQuality: 0.85) } /// 라이브러리 썸네일 겸 페이지 첫 표시 플레이스홀더의 해상도(세로 px). /// 본 렌더(백그라운드)가 도착할 때까지 잠깐 보이는 이미지라, 목록용보다 여유 있게 잡아 /// 플레이스홀더 단계의 흐릿함을 줄인다 (JPEG라 저장 부담은 수십 KB 수준). static let thumbnailSide: CGFloat = 512 /// 라이브러리 썸네일 (첫 쪽) static func thumbnailData(kind: DiaryTemplateKind, data: Data) -> Data? { switch kind { case .pdf: return renderPDFPage(data: data, pageIndex: 0, scale: thumbnailSide / DiaryPageMetrics.size.height)? .jpegData(compressionQuality: 0.8) case .image: guard let image = UIImage(data: data) else { return nil } let longest = max(image.size.width, image.size.height) guard longest > 0 else { return nil } let ratio = min(thumbnailSide / longest, 1) let target = CGSize(width: image.size.width * ratio, height: image.size.height * ratio) let format = UIGraphicsImageRendererFormat() format.scale = 1 return UIGraphicsImageRenderer(size: target, format: format).image { _ in image.draw(in: CGRect(origin: .zero, size: target)) }.jpegData(compressionQuality: 0.8) } } } // MARK: - 노트 페이지 배경 뷰 (줌 배율 연동) /// 양식 배경 이미지 뷰. PDF는 줌이 끝날 때 현재 배율로 백그라운드에서 다시 래스터해 /// 확대해도 흐려지지 않는다 (펜슬 캔버스의 선명도 규칙과 동일한 리듬). /// 첫 표시는 저장된 썸네일을 즉시 깔고 본 렌더를 백그라운드로 돌린다 — /// 복잡한 벡터 PDF도 페이지 넘김이 걸리지 않는다. /// 이미지 양식은 저장 해상도가 이미 상한이라 다시 그릴 필요가 없다. final class DiaryTemplateImageView: UIImageView { /// 표시 중인 양식·쪽 식별자 (SwiftUI 재사용 시 불필요한 재렌더 방지) var renderKey = "" private var pdfData: Data? private var pdfPageIndex = 0 private var renderedScale: CGFloat = 0 private var renderTask: Task? /// DiaryZoomScrollView가 지정하는 목표 래스터 배율 (기기 스케일 × 줌, 상한 3) var targetContentsScale: CGFloat = 0 { didSet { renderIfNeeded() } } /// 화면에 보이는(활성) 페이지만 고배율 비트맵을 유지한다 — TabView가 이웃 페이지를 /// 살려 두므로, 비활성 페이지는 2×로 강등해 양식 여러 장의 메모리를 절약한다. var allowsHighResolution = true { didSet { guard oldValue != allowsHighResolution else { return } renderIfNeeded() } } /// PDF 양식 지정 + 플레이스홀더(썸네일) 즉시 표시 → 본 렌더는 백그라운드 func setPDF(data: Data, pageIndex: Int, placeholder: UIImage?) { pdfData = data pdfPageIndex = pageIndex renderedScale = 0 image = placeholder if targetContentsScale <= 0 { targetContentsScale = 2 } renderIfNeeded() } /// 이미지 양식 지정 (재렌더 없음) func setImage(data: Data) { pdfData = nil renderedScale = 0 image = UIImage(data: data) } /// 비활성 페이지는 2×로 상한 (활성 복귀 시 원래 배율로 다시 올라간다) private var effectiveScale: CGFloat { allowsHighResolution ? targetContentsScale : min(targetContentsScale, 2) } private func renderIfNeeded() { guard pdfData != nil, effectiveScale > 0, abs(effectiveScale - renderedScale) > 0.01 else { return } scheduleRender() } private func scheduleRender() { guard let data = pdfData else { return } let scale = effectiveScale let index = pdfPageIndex renderedScale = scale renderTask?.cancel() renderTask = Task { [weak self] in // CoreGraphics PDF 렌더는 백그라운드에서 안전 — 페이지 넘김·핀치 직후 프레임 드랍 방지 let image = await Task.detached(priority: .userInitiated) { DiaryTemplateRenderer.renderPDFPage(data: data, pageIndex: index, scale: scale) }.value guard !Task.isCancelled else { return } guard let image else { // 렌더 실패(메모리 압박 등) — 배율 기록을 되돌려 다음 기회(줌/재표시)에 재시도 self?.renderedScale = 0 return } self?.image = image } } } /// 노트 페이지 ZStack에서 필기 아래에 깔리는 양식 배경 struct DiaryTemplateBackground: UIViewRepresentable { let template: DiaryTemplate let pageIndex: Int /// 현재 보이는 페이지인지 (비활성이면 고배율 비트맵을 내려 메모리 절약) var isActive = true func makeUIView(context: Context) -> DiaryTemplateImageView { let view = DiaryTemplateImageView() view.contentMode = .scaleAspectFit configure(view) return view } func updateUIView(_ view: DiaryTemplateImageView, context: Context) { configure(view) } private func configure(_ view: DiaryTemplateImageView) { view.allowsHighResolution = isActive let key = "\(template.uuid.uuidString)-\(pageIndex)" guard view.renderKey != key else { return } view.renderKey = key switch template.kind { case .pdf: view.setPDF( data: template.data, pageIndex: pageIndex, placeholder: template.thumbnailData.flatMap(UIImage.init(data:)) ) case .image: view.setImage(data: template.data) } } } // MARK: - 페이지 추가 선택 /// 새 노트 페이지의 배경 종류 enum DiaryPageStyle { case blank case lined case template(DiaryTemplate, pageIndex: Int) } /// 페이지 추가 시트: 빈 캔버스 / 줄 노트 / 내 양식(여러 쪽이면 2단계 쪽 선택) struct DiaryPageChooserSheet: View { let templates: [DiaryTemplate] let onPick: (DiaryPageStyle) -> Void @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { List { Section { basicRow(symbol: "square", title: "빈 캔버스") { pick(.blank) } basicRow(symbol: "text.justify", title: "줄 노트") { pick(.lined) } } Section("내 양식") { ForEach(templates, id: \.uuid) { template in if template.pageCount > 1 { // 여러 쪽 PDF: 쪽 썸네일을 펼쳐 고르는 2단계 NavigationLink { DiaryTemplatePagePicker(template: template) { index in pick(.template(template, pageIndex: index)) } } label: { DiaryTemplateRowLabel(template: template) } } else { Button { pick(.template(template, pageIndex: 0)) } label: { DiaryTemplateRowLabel(template: template) .contentShape(Rectangle()) } .buttonStyle(.plain) } } } } .scrollContentBackground(.hidden) .background(AppTheme.background) .navigationTitle("페이지 추가") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("닫기") { dismiss() } } } } .presentationDetents([.medium, .large]) } private func pick(_ style: DiaryPageStyle) { onPick(style) dismiss() } private func basicRow(symbol: String, title: LocalizedStringKey, action: @escaping () -> Void) -> some View { Button(action: action) { HStack(spacing: 12) { Image(systemName: symbol) .font(.system(size: 15, weight: .semibold)) .foregroundStyle(AppTheme.green) .frame(width: 30) Text(title) .foregroundStyle(.primary) Spacer() } .contentShape(Rectangle()) } .buttonStyle(.plain) } } /// 양식 한 줄 표시 (썸네일 + 이름 + 종류·쪽수) struct DiaryTemplateRowLabel: View { let template: DiaryTemplate var body: some View { HStack(spacing: 12) { Group { if let data = template.thumbnailData, let image = UIImage(data: data) { Image(uiImage: image) .resizable() .scaledToFit() } else { Image(systemName: "doc.text.image") .foregroundStyle(.tertiary) } } .frame(width: 44, height: 56) .background(Color.white, in: RoundedRectangle(cornerRadius: 6, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 6, style: .continuous).strokeBorder(.quaternary)) VStack(alignment: .leading, spacing: 2) { Text(template.name) .font(.subheadline.weight(.medium)) .foregroundStyle(.primary) .lineLimit(1) HStack(spacing: 4) { if template.kind == .pdf { Text(verbatim: "PDF") } else { Text("이미지") } if template.pageCount > 1 { Text("\(template.pageCount)쪽") } } .font(.caption2) .foregroundStyle(.secondary) } } } } /// 여러 쪽 PDF 양식의 쪽 선택 그리드 (썸네일은 지연 생성) private struct DiaryTemplatePagePicker: View { let template: DiaryTemplate let onPick: (Int) -> Void @State private var thumbnails: [Int: UIImage] = [:] var body: some View { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 140, maximum: 190), spacing: 12)], spacing: 14) { ForEach(0.. 0 { Text("‘\(template.name)’ 양식을 삭제할까요? 이 양식을 쓰는 노트 페이지 \(usage)개는 빈 캔버스로 표시돼요. (필기와 요소는 그대로 남아요)") } else { Text("‘\(template.name)’ 양식을 삭제할까요?") } } } private func row(_ template: DiaryTemplate) -> some View { HStack { DiaryTemplateRowLabel(template: template) Spacer() let usage = usageCounts[template.uuid.uuidString] ?? 0 if usage > 0 { Text("페이지 \(usage)개 사용 중") .font(.caption2) .foregroundStyle(.tertiary) } } .swipeActions { Button("삭제", role: .destructive) { deletingTemplate = template } Button("이름 변경") { renameText = template.name renamingTemplate = template } .tint(AppTheme.green) } } /// 양식별 사용 중 노트 페이지 수 — 행마다 전체 페이지를 다시 훑지 않게 한 번에 집계 private var usageCounts: [String: Int] { var counts: [String: Int] = [:] for page in allPages where !page.templateID.isEmpty { counts[page.templateID, default: 0] += 1 } return counts } /// 이 양식을 배경으로 쓰는 노트 페이지 수 (삭제 경고용) private func usageCount(_ template: DiaryTemplate) -> Int { usageCounts[template.uuid.uuidString] ?? 0 } // MARK: 가져오기 private func importPDF(_ result: Result) { guard case .success(let url) = result else { return } let accessing = url.startAccessingSecurityScopedResource() defer { if accessing { url.stopAccessingSecurityScopedResource() } } guard let data = try? Data(contentsOf: url) else { noticeMessage = String(localized: "파일을 읽지 못했어요. 다시 시도해 주세요.") return } guard data.count <= DiaryTemplateRenderer.maxPDFBytes else { noticeMessage = String(localized: "PDF가 너무 커요. 20MB 이하 파일만 가져올 수 있어요.") return } guard let count = DiaryTemplateRenderer.pdfPageCount(data), count > 0 else { noticeMessage = String(localized: "PDF를 열 수 없어요. 파일이 손상됐을 수 있어요.") return } let template = DiaryTemplate(name: url.deletingPathExtension().lastPathComponent, kind: .pdf) template.data = data template.pageCount = min(count, DiaryTemplateRenderer.maxPages) template.thumbnailData = DiaryTemplateRenderer.thumbnailData(kind: .pdf, data: data) context.insert(template) try? context.save() if count > DiaryTemplateRenderer.maxPages { noticeMessage = String(localized: "쪽이 많아 처음 \(DiaryTemplateRenderer.maxPages)쪽까지만 사용해요.") } } private func importPhoto(_ image: UIImage) { guard let data = DiaryTemplateRenderer.normalizedImageData(image) else { noticeMessage = String(localized: "파일을 읽지 못했어요. 다시 시도해 주세요.") return } let template = DiaryTemplate(name: String(localized: "사진 양식"), kind: .image) template.data = data template.pageCount = 1 template.thumbnailData = DiaryTemplateRenderer.thumbnailData(kind: .image, data: data) context.insert(template) try? context.save() } }