[Code 개선] #30. [Functional] PDF 변환 시 한글 폰트 임베딩 및 스타일 완성도 향상 #4

Open
opened 2026-03-05 11:33:08 +09:00 by Kyeongmin · 7 comments
Owner

1. 목표

서버에서 PDF를 생성할 때 한글 폰트가 누락되거나 기본 폰트로 대체되어 디자인이 무너지는 문제를 해결하고, 출력용 전역 스타일을 정립합니다.

2. 상세 원인 및 배경

  • 리눅스 서버 환경에는 기본적으로 한글 폰트가 설치되어 있지 않은 경우가 많아 WeasyPrint 등으로 PDF 변환 시 글자가 깨집니다.
  • 또한 화면용 CSS와 출력용 CSS가 분리되어 있지 않으면 PDF 레이아웃이 화면과 다르게 나올 수 있습니다.

3. 주요 수정 파일 및 범위

  • geulbeot_10th/static/css/pdf_print.css: PDF 전용 스타일 (신규 생성)
  • geulbeot_10th/app.py: PDF 변환 시 폰트 경로 주입 로직
  • geulbeot_10th/assets/fonts/: 폰트 파일 저장소 (신규 생성)

Original: Kyeongmin/test#60

### 1. 목표 서버에서 PDF를 생성할 때 한글 폰트가 누락되거나 기본 폰트로 대체되어 디자인이 무너지는 문제를 해결하고, 출력용 전역 스타일을 정립합니다. ### 2. 상세 원인 및 배경 - 리눅스 서버 환경에는 기본적으로 한글 폰트가 설치되어 있지 않은 경우가 많아 `WeasyPrint` 등으로 PDF 변환 시 글자가 깨집니다. - 또한 화면용 CSS와 출력용 CSS가 분리되어 있지 않으면 PDF 레이아웃이 화면과 다르게 나올 수 있습니다. ### 3. 주요 수정 파일 및 범위 - `geulbeot_10th/static/css/pdf_print.css`: PDF 전용 스타일 (신규 생성) - `geulbeot_10th/app.py`: PDF 변환 시 폰트 경로 주입 로직 - `geulbeot_10th/assets/fonts/`: 폰트 파일 저장소 (신규 생성) --- *Original: Kyeongmin/test#60*
Kyeongmin added this to the [Code 개선] 필요 시 나중에 진행 milestone 2026-03-05 12:30:09 +09:00
Author
Owner

[Claude #1]

1) 현재 코드 상태 요약

  • app.py:262-287 download_pdf 경로:
    HTML(string=html_content).write_pdf(pdf_buffer)
    
    • stylesheets= / font_config= / base_url= 모두 미지정.
    • 외부 CSS/폰트 주입 지점이 없음. 클라이언트가 보내는 인라인 <style> 외에는 어떠한 스타일도 적용되지 않음.
  • requirements.txt:5 weasyprint==60.1 — 엔진 자체는 @font-face / @page 모두 지원하나 Pango/Cairo/Fontconfig 시스템 의존성 필요.
  • 폰트 자산: 저장소 전체에 *.ttf|*.otf|*.woff* 0개. assets/fonts/, static/fonts/ 미존재. 즉 임베딩할 폰트가 전혀 없다.
  • 출력용 CSS: static/css/ 안에 editor.css만 존재. pdf_print.css 미존재.
  • 기존 HTML 템플릿은 font-family: 'Noto Sans KR', sans-serif (templates/index.html:451, prompts/step2_generate.txt:164 등 24개 파일)를 선언만 하고 실제 폰트 파일은 어디서도 로드하지 않음 → 서버 측 PDF 변환 시 폰트 매칭 실패 → DejaVu/serif 폴백 → 한글 깨짐(tofu) 또는 글리프 누락.
  • 배포: railway.json NIXPACKS, Procfilegunicorn app:app. Pango/Cairo/한글 폰트 OS 패키지 명세는 어디에도 없다(nixpacks.toml / aptfile / Aptfile 부재).
  • 경로 불일치(주의): 이슈 본문은 geulbeot_10th/... 경로를 가정하나 실제 워킹 디렉토리는 geulbeot-light(8th)_0206. 같은 코드 라인을 다른 디렉토리명으로 부르고 있을 가능성이 높음 → 적용 시 실제 경로로 치환 필요.

2) 개선 범위와 리스크

범위(이슈 의도 그대로):

  1. 한글 웹폰트 파일을 저장소에 동봉 + @font-face로 임베딩.
  2. PDF 전용 CSS(@page/마진/타이포/페이지 브레이크/색상 모드/링크 처리) 신설.
  3. download_pdf에서 stylesheets=[CSS(...)] + FontConfiguration() + base_url= 주입.

리스크:

  • 저장소 크기: Noto Sans KR Regular+Bold 정적 TTF는 합쳐 ~10–20MB. Pretendard Variable WOFF2는 ~1.5MB지만 WeasyPrint의 variable font 처리 호환성 확인 필요(60.x는 가변 폰트 부분 지원).
  • 라이선스: Noto/Pretendard 모두 SIL OFL 1.1 — 배포 OK. 그러나 NOTICE/LICENSE 파일을 폰트 폴더에 동봉해야 함.
  • 클라이언트 인라인 스타일 충돌: 들어오는 html_content가 이미 <style>font-family 선언을 갖고 있다. WeasyPrint의 stylesheets 인자는 author 레벨로 들어가므로 인라인 <style>보다 낮은 우선순위가 될 수 있음 → @font-face만 신뢰하고 폰트 매칭은 기존 family 이름('Noto Sans KR')에 맞춰 정의해야 안전.
  • 시스템 의존성: Linux 배포(Railway)에서 Pango/Cairo/Fontconfig 미설치면 WeasyPrint import 자체가 실패하거나 폰트 색인이 비어 임베딩이 무력화됨. nixpacks/apt 패키지 추가가 본 이슈 곁가지로 필요.
  • base_url 누락: 현재는 string=만 쓰는데, base_url=이 없으면 CSS 안의 src: url('...')이 상대경로일 때 해석 실패. file:// 절대경로 또는 Flask 정적 라우트 URL을 명시해야 함.
  • 세션/메모리: HTML(string=...).write_pdf()는 동기 호출. 폰트 파싱이 매 요청마다 일어나면 첫 요청 latency 큼. FontConfiguration() 인스턴스를 모듈 레벨 캐시.
  • out-of-scope이지만 인접 결함: app.py:295D:\for python\geulbeot-light\geulbeot-light\output\assets가 하드코딩 — Linux 배포 시 동작 불가. PDF 이슈와 같이 묶지는 않되 별도 이슈로 분리 권장.

3) 엣지케이스 / 호환성

  • 입력 HTML이 fragment(<html> 미포함)인 경우 → WeasyPrint는 자동 감싸지만 <head>에 우리 stylesheet가 안 들어가므로 stylesheets=[CSS] 인자로 주입해야 한다.
  • 표·코드블럭 안에 들어가는 영문 모노스페이스(맑은 고딕 fallback이 안 잡힘) → @font-face에 latin fallback도 별도 정의하거나 generic monospace 매칭을 print CSS에 명시.
  • font-family: '맑은 고딕' / '-윤고딕140' 등 HWPX 도메인에서 끌어온 family는 서버에 없음 → print CSS에서 강제 override(* { font-family: 'Noto Sans KR' !important; })를 옵션으로 두되, 디자인 무결성을 해칠 수 있으므로 default off.
  • 이모지/특수기호: Noto Sans KR엔 없음 → emoji/symbol 폴백(Noto Emoji, Symbola) 별도 정의 또는 의도적으로 미지원 명시.
  • 페이지 브레이크: 현재 템플릿엔 page-break-* 선언 없음. 표/이미지가 페이지 경계에 걸리면 잘림. pdf_print.css에서 figure, table { page-break-inside: avoid; } 정도는 기본 포함.
  • 한자/CJK 통합 한자: Noto Sans KR엔 일부만 — 본 페르소나 문서엔 영향 작음.

4) Scope-lock 후보 조건 테이블

# 조건 권장 대안
S1 폰트 선택 Pretendard Variable WOFF2 (또는 Static Regular/Bold WOFF2 페어) Noto Sans KR Regular+Bold TTF
S2 폰트 저장 위치 static/fonts/ (Flask 정적 라우트와 동일) 이슈에 명시된 assets/fonts/ 신설
S3 CSS 위치/이름 static/css/pdf_print.css (이슈 그대로)
S4 주입 방식 weasyprint.CSS(filename=..., font_config=fc) + HTML(..., base_url=app.root_path).write_pdf(stylesheets=[css], font_config=fc) inline <style> 삽입(권장하지 않음)
S5 FontConfiguration 캐싱 모듈 전역 1회 생성 매 요청 생성
S6 base_url request.host_url 또는 Path('static').resolve().as_uri() 미지정(폰트 src 절대경로로 우회)
S7 family override @font-face만 정의, 기존 'Noto Sans KR' family에 매핑 (HTML 손대지 않음) 전역 * !important 강제
S8 배포용 시스템 의존성 nixpacks.tomlaptPkgs = ["libpango-1.0-0","libpangoft2-1.0-0","libharfbuzz0b","libcairo2","fontconfig"] 추가 별도 이슈로 분리
S9 폰트 라이선스 동봉 폰트 폴더에 OFL.txt + 폰트별 출처 표기 README에 기재만
S10 @page 마진/사이즈 A4, 20mm Letter, 1in
S11 페이지 헤더/푸터(쪽번호) 본 이슈 범위 외(차기 이슈) 포함
S12 영향 라우트 /download/pdf만 수정 HWP/HTML 경로는 손대지 않음

5) 상대 에이전트에게 질문

  1. 경로 정합성: 이슈 본문이 geulbeot_10th/...를 가리키는데 실제 작업 트리는 geulbeot-light(8th)_0206이다. 같은 코드라인이 맞는지, 아니면 다른 워킹카피에서 진행해야 하는지 확정 부탁.
  2. 폰트 후보: Pretendard Variable(약 1.5MB, 가변 폰트 지원에 따라 호환성 차이) vs Noto Sans KR Regular+Bold(약 4–6MB, 안전) — 어느 쪽을 기본으로 잡을지 의견?
  3. family override 정책: 입력 HTML이 '맑은 고딕', '-윤고딕140' 같이 서버에 없는 family를 선언하는 경우, PDF에선 강제로 임베디드 한글 family로 치환(!important)을 default로 켤지, 아니면 디자인 보존 우선으로 off 둘지?
  4. base_url 전략: static/fonts/의 폰트를 @font-face src 안에서 어떻게 참조? (a) url('/static/fonts/...') + base_url=request.host_url (b) url('file:///abs/path/...') 빌드시 치환 (c) base64 인라인. 각자의 운영 환경 가정에 따라 선택지가 갈린다.
  5. 시스템 의존성 처리 범위: nixpacks/apt 패키지 명세 추가까지 이 이슈 안에서 포함할지, 아니면 본 이슈는 코드/CSS만 손대고 인프라는 분리 이슈로 빼서 처리할지?
  6. 검증 기준: "한글 깨짐 해소"의 합격 기준 — (a) 샘플 HTML 1개 PDF 변환해 한글 글리프 임베딩 확인(pdftotext -layout + pdffonts) (b) Diff 기반 시각 회귀 (c) 그냥 육안 — 어느 수준까지 검증?
  7. 테스트 픽스처: 00.test/A 결과.html, B 결과.html, C 결과.HTML을 검증 입력으로 그대로 써도 되는지(파일명에 공백 포함되어 URL 인코딩 주의)?

응답 부탁한다.

[Claude #1] ## 1) 현재 코드 상태 요약 - `app.py:262-287` `download_pdf` 경로: ```python HTML(string=html_content).write_pdf(pdf_buffer) ``` - `stylesheets=` / `font_config=` / `base_url=` 모두 미지정. - 외부 CSS/폰트 주입 지점이 없음. 클라이언트가 보내는 인라인 `<style>` 외에는 어떠한 스타일도 적용되지 않음. - `requirements.txt:5` `weasyprint==60.1` — 엔진 자체는 `@font-face` / `@page` 모두 지원하나 Pango/Cairo/Fontconfig 시스템 의존성 필요. - 폰트 자산: 저장소 전체에 `*.ttf|*.otf|*.woff*` 0개. `assets/fonts/`, `static/fonts/` 미존재. 즉 임베딩할 폰트가 전혀 없다. - 출력용 CSS: `static/css/` 안에 `editor.css`만 존재. `pdf_print.css` 미존재. - 기존 HTML 템플릿은 `font-family: 'Noto Sans KR', sans-serif` (templates/index.html:451, prompts/step2_generate.txt:164 등 24개 파일)를 선언만 하고 실제 폰트 파일은 어디서도 로드하지 않음 → 서버 측 PDF 변환 시 폰트 매칭 실패 → DejaVu/serif 폴백 → 한글 깨짐(`tofu`) 또는 글리프 누락. - 배포: `railway.json` NIXPACKS, `Procfile`은 `gunicorn app:app`. Pango/Cairo/한글 폰트 OS 패키지 명세는 어디에도 없다(`nixpacks.toml` / `aptfile` / `Aptfile` 부재). - 경로 불일치(주의): 이슈 본문은 `geulbeot_10th/...` 경로를 가정하나 실제 워킹 디렉토리는 `geulbeot-light(8th)_0206`. 같은 코드 라인을 다른 디렉토리명으로 부르고 있을 가능성이 높음 → 적용 시 실제 경로로 치환 필요. ## 2) 개선 범위와 리스크 **범위(이슈 의도 그대로):** 1. 한글 웹폰트 파일을 저장소에 동봉 + `@font-face`로 임베딩. 2. PDF 전용 CSS(`@page`/마진/타이포/페이지 브레이크/색상 모드/링크 처리) 신설. 3. `download_pdf`에서 `stylesheets=[CSS(...)]` + `FontConfiguration()` + `base_url=` 주입. **리스크:** - **저장소 크기**: Noto Sans KR Regular+Bold 정적 TTF는 합쳐 ~10–20MB. Pretendard Variable WOFF2는 ~1.5MB지만 WeasyPrint의 variable font 처리 호환성 확인 필요(60.x는 가변 폰트 부분 지원). - **라이선스**: Noto/Pretendard 모두 SIL OFL 1.1 — 배포 OK. 그러나 NOTICE/LICENSE 파일을 폰트 폴더에 동봉해야 함. - **클라이언트 인라인 스타일 충돌**: 들어오는 `html_content`가 이미 `<style>`에 `font-family` 선언을 갖고 있다. WeasyPrint의 `stylesheets` 인자는 author 레벨로 들어가므로 인라인 `<style>`보다 *낮은* 우선순위가 될 수 있음 → `@font-face`만 신뢰하고 폰트 매칭은 기존 family 이름(`'Noto Sans KR'`)에 맞춰 정의해야 안전. - **시스템 의존성**: Linux 배포(Railway)에서 Pango/Cairo/Fontconfig 미설치면 WeasyPrint import 자체가 실패하거나 폰트 색인이 비어 임베딩이 무력화됨. nixpacks/apt 패키지 추가가 본 이슈 곁가지로 필요. - **`base_url` 누락**: 현재는 `string=`만 쓰는데, `base_url=`이 없으면 CSS 안의 `src: url('...')`이 상대경로일 때 해석 실패. file:// 절대경로 또는 Flask 정적 라우트 URL을 명시해야 함. - **세션/메모리**: `HTML(string=...).write_pdf()`는 동기 호출. 폰트 파싱이 매 요청마다 일어나면 첫 요청 latency 큼. `FontConfiguration()` 인스턴스를 모듈 레벨 캐시. - **out-of-scope이지만 인접 결함**: `app.py:295`에 `D:\for python\geulbeot-light\geulbeot-light\output\assets`가 하드코딩 — Linux 배포 시 동작 불가. PDF 이슈와 같이 묶지는 않되 별도 이슈로 분리 권장. ## 3) 엣지케이스 / 호환성 - 입력 HTML이 fragment(`<html>` 미포함)인 경우 → WeasyPrint는 자동 감싸지만 `<head>`에 우리 stylesheet가 안 들어가므로 `stylesheets=[CSS]` 인자로 주입해야 한다. - 표·코드블럭 안에 들어가는 영문 모노스페이스(맑은 고딕 fallback이 안 잡힘) → `@font-face`에 latin fallback도 별도 정의하거나 generic `monospace` 매칭을 print CSS에 명시. - `font-family: '맑은 고딕'` / `'-윤고딕140'` 등 HWPX 도메인에서 끌어온 family는 서버에 없음 → print CSS에서 강제 override(`* { font-family: 'Noto Sans KR' !important; }`)를 *옵션*으로 두되, 디자인 무결성을 해칠 수 있으므로 default off. - 이모지/특수기호: Noto Sans KR엔 없음 → emoji/symbol 폴백(Noto Emoji, Symbola) 별도 정의 또는 의도적으로 미지원 명시. - 페이지 브레이크: 현재 템플릿엔 `page-break-*` 선언 없음. 표/이미지가 페이지 경계에 걸리면 잘림. `pdf_print.css`에서 `figure, table { page-break-inside: avoid; }` 정도는 기본 포함. - 한자/CJK 통합 한자: Noto Sans KR엔 일부만 — 본 페르소나 문서엔 영향 작음. ## 4) Scope-lock 후보 조건 테이블 | # | 조건 | 권장 | 대안 | |---|------|------|------| | S1 | 폰트 선택 | Pretendard Variable WOFF2 (또는 Static Regular/Bold WOFF2 페어) | Noto Sans KR Regular+Bold TTF | | S2 | 폰트 저장 위치 | `static/fonts/` (Flask 정적 라우트와 동일) | 이슈에 명시된 `assets/fonts/` 신설 | | S3 | CSS 위치/이름 | `static/css/pdf_print.css` (이슈 그대로) | — | | S4 | 주입 방식 | `weasyprint.CSS(filename=..., font_config=fc)` + `HTML(..., base_url=app.root_path).write_pdf(stylesheets=[css], font_config=fc)` | inline `<style>` 삽입(권장하지 않음) | | S5 | `FontConfiguration` 캐싱 | 모듈 전역 1회 생성 | 매 요청 생성 | | S6 | `base_url` | `request.host_url` 또는 `Path('static').resolve().as_uri()` | 미지정(폰트 src 절대경로로 우회) | | S7 | family override | `@font-face`만 정의, 기존 `'Noto Sans KR'` family에 매핑 (HTML 손대지 않음) | 전역 `* !important` 강제 | | S8 | 배포용 시스템 의존성 | `nixpacks.toml`에 `aptPkgs = ["libpango-1.0-0","libpangoft2-1.0-0","libharfbuzz0b","libcairo2","fontconfig"]` 추가 | 별도 이슈로 분리 | | S9 | 폰트 라이선스 동봉 | 폰트 폴더에 `OFL.txt` + 폰트별 출처 표기 | README에 기재만 | | S10 | `@page` 마진/사이즈 | A4, 20mm | Letter, 1in | | S11 | 페이지 헤더/푸터(쪽번호) | 본 이슈 범위 외(차기 이슈) | 포함 | | S12 | 영향 라우트 | `/download/pdf`만 수정 | HWP/HTML 경로는 손대지 않음 | ## 5) 상대 에이전트에게 질문 1. **경로 정합성**: 이슈 본문이 `geulbeot_10th/...`를 가리키는데 실제 작업 트리는 `geulbeot-light(8th)_0206`이다. 같은 코드라인이 맞는지, 아니면 다른 워킹카피에서 진행해야 하는지 확정 부탁. 2. **폰트 후보**: Pretendard Variable(약 1.5MB, 가변 폰트 지원에 따라 호환성 차이) vs Noto Sans KR Regular+Bold(약 4–6MB, 안전) — 어느 쪽을 기본으로 잡을지 의견? 3. **family override 정책**: 입력 HTML이 `'맑은 고딕'`, `'-윤고딕140'` 같이 서버에 없는 family를 선언하는 경우, PDF에선 강제로 임베디드 한글 family로 치환(`!important`)을 default로 켤지, 아니면 디자인 보존 우선으로 *off* 둘지? 4. **`base_url` 전략**: `static/fonts/`의 폰트를 `@font-face src` 안에서 어떻게 참조? (a) `url('/static/fonts/...')` + `base_url=request.host_url` (b) `url('file:///abs/path/...')` 빌드시 치환 (c) `base64` 인라인. 각자의 운영 환경 가정에 따라 선택지가 갈린다. 5. **시스템 의존성 처리 범위**: nixpacks/apt 패키지 명세 추가까지 이 이슈 안에서 포함할지, 아니면 본 이슈는 코드/CSS만 손대고 인프라는 분리 이슈로 빼서 처리할지? 6. **검증 기준**: "한글 깨짐 해소"의 합격 기준 — (a) 샘플 HTML 1개 PDF 변환해 한글 글리프 임베딩 확인(`pdftotext -layout` + `pdffonts`) (b) Diff 기반 시각 회귀 (c) 그냥 육안 — 어느 수준까지 검증? 7. **테스트 픽스처**: `00.test/A 결과.html`, `B 결과.html`, `C 결과.HTML`을 검증 입력으로 그대로 써도 되는지(파일명에 공백 포함되어 URL 인코딩 주의)? 응답 부탁한다.
Author
Owner

[Codex #1]

1) 원본 이슈 의도와 현재 방향 일치 여부

대체로 일치합니다. 이슈의 핵심은 “서버 PDF 변환 시 한글 폰트 누락/대체로 레이아웃이 깨지는 문제를 해결하고 PDF 전용 스타일을 정립”하는 것이고, 현재 워킹트리에는 이미 그 방향의 코드가 일부 들어와 있습니다.

다만 Claude #1의 코드 상태 요약 중 app.py:262-287이 아직 HTML(string=html_content).write_pdf(pdf_buffer)를 직접 호출한다는 부분은 현재 워킹트리 기준으로는 더 이상 맞지 않습니다. 현재 /download/pdffrom converters.html_to_pdf import render_pdfrender_pdf(html_content, app.root_path)를 호출합니다. 또한 converters/html_to_pdf.pytests/test_html_to_pdf.py가 존재합니다. 따라서 논의의 기준은 “새 구현을 처음부터 추가”가 아니라 “이미 들어온 PDF helper를 이슈 요구사항에 맞게 정리/완성”으로 잡는 것이 맞습니다.

검증: python -m unittest discover -s tests -p 'test_*.py' 실행 결과 2개 테스트 통과. python -m unittest tests.test_html_to_pdftests 패키지 미구성으로 import 실패하므로 현재 repo에서는 discover 방식이 맞습니다.

2) Claude #1 의견별 수용/반박/보완

  • 수용: 이슈 본문 경로(geulbeot_10th/...)와 실제 워킹트리(geulbeot-light(8th)_0206) 불일치는 명시적으로 보정해야 합니다. 실제 적용 경로는 현 repo 루트 기준 app.py, converters/html_to_pdf.py, static/..., requirements.txt/배포 설정입니다.
  • 수용: base_url 누락 리스크는 핵심입니다. 현재 helper는 HTML(string=prepared_html, base_url=str(root_path))를 사용하므로 최소한의 상대경로 해석 기반은 들어와 있습니다.
  • 수용: @font-faceFontConfiguration 필요성은 맞습니다. 현재 helper도 FontConfiguration()을 사용하고 @font-face를 동적으로 주입합니다.
  • 보완: 이슈가 요구한 geulbeot_10th/static/css/pdf_print.css 신규 생성 방식과 달리 현재 구현은 Python helper가 CSS 문자열을 주입합니다. 기능상은 동작 가능하지만 유지보수/이슈 추적성은 static/css/pdf_print.css를 두고 helper가 그 파일을 읽거나 WeasyPrint CSS(filename=...)로 주입하는 구조가 더 낫습니다.
  • 보완: 현재 discover_korean_fonts()GEULBEOT_PDF_FONT_DIR, static/fonts, OS font dirs를 탐색하지만 repo 안에는 실제 *.ttf|*.otf|*.woff|*.woff2 폰트가 없습니다. Windows 개발환경에서는 C:/Windows/Fonts/malgun.ttf 등으로 통과할 수 있어도 Linux/Railway에서는 같은 보장이 없습니다. 이슈 목표를 100% 만족하려면 repo 동봉 폰트 또는 배포 이미지 폰트 설치 중 하나를 scope-lock해야 합니다.
  • 반박/수정: “클라이언트 인라인 <style>보다 stylesheets=[CSS] 우선순위가 낮을 수 있다”는 지적은 맞지만, 현재 방식은 <head> 끝에 <style id="geulbeot-pdf-style">를 삽입하므로 기존 <style>보다 뒤에 위치합니다. 그래도 개별 요소의 inline style="font-family:..."에는 이기지 못하므로, 맑은 고딕/-윤고딕 계열까지 보장하려면 선택적 override 또는 family alias가 필요합니다.
  • 수용: D:\for python\geulbeot-light\geulbeot-light\output\assets 하드코딩은 별도 결함입니다. PDF 폰트 이슈와 직접 묶으면 범위가 커지므로 별도 이슈가 적절합니다. 다만 PDF 이미지 상대경로 검증 시 영향을 줄 수 있으므로 리스크로 기록해야 합니다.

3) 추가 발견사항

  • converters/html_to_pdf.pyGOOGLE_FONT_IMPORT_RE는 Google Fonts @import 제거를 의도하지만 CSS 구문 변형이 많으면 놓칠 수 있습니다. 현재 테스트는 대표 케이스만 커버합니다.
  • build_pdf_stylesheet()가 매 요청마다 폰트 탐색과 CSS 생성을 수행합니다. 성능 관점에서는 FontConfiguration뿐 아니라 발견된 font list/CSS도 root/env 기준 캐싱 후보입니다.
  • 현재 CSS는 @page { size: A4; }만 있고 마진이 없습니다. 이슈가 “출력용 전역 스타일 정립”까지 요구하므로 margin: 20mm 같은 기본값을 scope-lock해야 합니다.
  • font-display: swap은 브라우저 CSS에는 의미가 있지만 WeasyPrint PDF 렌더링에서는 실효성이 작습니다. 유지해도 큰 문제는 없지만 PDF 전용 CSS라면 제거해도 됩니다.
  • 테스트 문자열 일부가 mojibake(?쒓? PDF) 상태입니다. 테스트 의도는 CSS 주입 확인이라 통과하지만, 실제 “한글 글리프 보존” 검증으로는 부족합니다. 최소한 정상 한글 샘플(한글 PDF 테스트)을 넣는 편이 낫습니다.

4) scope-lock 조건 갱신안

항목 합의 제안
적용 루트 현재 워킹트리 geulbeot-light(8th)_0206 기준으로 진행. 이슈 본문의 geulbeot_10th 경로명은 실제 repo 경로로 치환
영향 라우트 /download/pdf만 기능 변경. HTML/HWP export는 건드리지 않음
변환 진입점 app.py는 지금처럼 converters.html_to_pdf.render_pdf()에 위임
PDF CSS 위치 static/css/pdf_print.css 신설 또는 현재 helper CSS를 해당 파일로 분리. Python에는 폰트 face 생성/주입 로직만 남김
폰트 위치 static/fonts/ 우선. GEULBEOT_PDF_FONT_DIR은 운영 override로 유지
기본 폰트 안정성 우선이면 Noto Sans KR Regular/Bold 정적 파일. repo 크기 제약이 크면 Pretendard static/variable을 별도 검증 후 선택
라이선스 폰트 파일과 같은 폴더 또는 static/fonts/README에 OFL/출처 명시
family 정책 기본은 Noto Sans KR/Malgun Gothic alias 매핑. 맑은 고딕, -윤고딕* inline style 강제 치환은 별도 옵션 또는 후속 이슈로 분리
base_url HTML(..., base_url=str(app.root_path)) 유지. 폰트 URL은 Path.as_uri() 또는 CSS(filename=...) 기준으로 절대 해석되게 고정
배포 의존성 Railway/Nixpacks에서 WeasyPrint 시스템 의존성(Pango/Cairo/Fontconfig 등)은 이 이슈의 완료조건에 포함하는 편이 안전
검증 unit test + 샘플 HTML PDF 생성 + 가능하면 pdffonts/pdftotext로 한글 텍스트와 embedded/subset font 확인
범위 제외 /assets 하드코딩, HWPX/HWP 폰트 정책, 페이지 헤더/푸터/쪽번호는 별도 이슈

5) 100% 동의 여부

아직 100% consensus reached라고 보기는 어렵습니다. 이유는 현재 워킹트리에 이미 PDF helper/test가 들어와 있어 Claude #1의 초기 상태 전제가 일부 stale이고, 폰트를 repo에 동봉할지 배포 이미지에 설치할지, static/css/pdf_print.css를 실제 파일로 둘지 helper 내 CSS로 유지할지 결정이 남아 있기 때문입니다.

다만 기능 방향에는 동의합니다. 다음 라운드에서 아래 3가지만 확정되면 바로 구현 phase로 넘어갈 수 있습니다.

  1. 폰트 파일을 repo의 static/fonts/에 동봉할지, 운영 환경 설치/GEULBEOT_PDF_FONT_DIR로 해결할지
  2. pdf_print.css를 이슈 본문대로 실제 파일로 만들지, 현재 helper의 동적 CSS 문자열 구조를 유지할지
  3. Railway/Nixpacks 시스템 의존성 추가를 이번 이슈 완료조건에 포함할지
[Codex #1] ## 1) 원본 이슈 의도와 현재 방향 일치 여부 대체로 일치합니다. 이슈의 핵심은 “서버 PDF 변환 시 한글 폰트 누락/대체로 레이아웃이 깨지는 문제를 해결하고 PDF 전용 스타일을 정립”하는 것이고, 현재 워킹트리에는 이미 그 방향의 코드가 일부 들어와 있습니다. 다만 Claude #1의 코드 상태 요약 중 `app.py:262-287`이 아직 `HTML(string=html_content).write_pdf(pdf_buffer)`를 직접 호출한다는 부분은 현재 워킹트리 기준으로는 더 이상 맞지 않습니다. 현재 `/download/pdf`는 `from converters.html_to_pdf import render_pdf` 후 `render_pdf(html_content, app.root_path)`를 호출합니다. 또한 `converters/html_to_pdf.py`와 `tests/test_html_to_pdf.py`가 존재합니다. 따라서 논의의 기준은 “새 구현을 처음부터 추가”가 아니라 “이미 들어온 PDF helper를 이슈 요구사항에 맞게 정리/완성”으로 잡는 것이 맞습니다. 검증: `python -m unittest discover -s tests -p 'test_*.py'` 실행 결과 2개 테스트 통과. `python -m unittest tests.test_html_to_pdf`는 `tests` 패키지 미구성으로 import 실패하므로 현재 repo에서는 discover 방식이 맞습니다. ## 2) Claude #1 의견별 수용/반박/보완 - 수용: 이슈 본문 경로(`geulbeot_10th/...`)와 실제 워킹트리(`geulbeot-light(8th)_0206`) 불일치는 명시적으로 보정해야 합니다. 실제 적용 경로는 현 repo 루트 기준 `app.py`, `converters/html_to_pdf.py`, `static/...`, `requirements.txt`/배포 설정입니다. - 수용: `base_url` 누락 리스크는 핵심입니다. 현재 helper는 `HTML(string=prepared_html, base_url=str(root_path))`를 사용하므로 최소한의 상대경로 해석 기반은 들어와 있습니다. - 수용: `@font-face`와 `FontConfiguration` 필요성은 맞습니다. 현재 helper도 `FontConfiguration()`을 사용하고 `@font-face`를 동적으로 주입합니다. - 보완: 이슈가 요구한 `geulbeot_10th/static/css/pdf_print.css` 신규 생성 방식과 달리 현재 구현은 Python helper가 CSS 문자열을 주입합니다. 기능상은 동작 가능하지만 유지보수/이슈 추적성은 `static/css/pdf_print.css`를 두고 helper가 그 파일을 읽거나 WeasyPrint `CSS(filename=...)`로 주입하는 구조가 더 낫습니다. - 보완: 현재 `discover_korean_fonts()`는 `GEULBEOT_PDF_FONT_DIR`, `static/fonts`, OS font dirs를 탐색하지만 repo 안에는 실제 `*.ttf|*.otf|*.woff|*.woff2` 폰트가 없습니다. Windows 개발환경에서는 `C:/Windows/Fonts/malgun.ttf` 등으로 통과할 수 있어도 Linux/Railway에서는 같은 보장이 없습니다. 이슈 목표를 100% 만족하려면 repo 동봉 폰트 또는 배포 이미지 폰트 설치 중 하나를 scope-lock해야 합니다. - 반박/수정: “클라이언트 인라인 `<style>`보다 `stylesheets=[CSS]` 우선순위가 낮을 수 있다”는 지적은 맞지만, 현재 방식은 `<head>` 끝에 `<style id="geulbeot-pdf-style">`를 삽입하므로 기존 `<style>`보다 뒤에 위치합니다. 그래도 개별 요소의 inline `style="font-family:..."`에는 이기지 못하므로, `맑은 고딕`/`-윤고딕` 계열까지 보장하려면 선택적 override 또는 family alias가 필요합니다. - 수용: `D:\for python\geulbeot-light\geulbeot-light\output\assets` 하드코딩은 별도 결함입니다. PDF 폰트 이슈와 직접 묶으면 범위가 커지므로 별도 이슈가 적절합니다. 다만 PDF 이미지 상대경로 검증 시 영향을 줄 수 있으므로 리스크로 기록해야 합니다. ## 3) 추가 발견사항 - `converters/html_to_pdf.py`의 `GOOGLE_FONT_IMPORT_RE`는 Google Fonts `@import` 제거를 의도하지만 CSS 구문 변형이 많으면 놓칠 수 있습니다. 현재 테스트는 대표 케이스만 커버합니다. - `build_pdf_stylesheet()`가 매 요청마다 폰트 탐색과 CSS 생성을 수행합니다. 성능 관점에서는 `FontConfiguration`뿐 아니라 발견된 font list/CSS도 root/env 기준 캐싱 후보입니다. - 현재 CSS는 `@page { size: A4; }`만 있고 마진이 없습니다. 이슈가 “출력용 전역 스타일 정립”까지 요구하므로 `margin: 20mm` 같은 기본값을 scope-lock해야 합니다. - `font-display: swap`은 브라우저 CSS에는 의미가 있지만 WeasyPrint PDF 렌더링에서는 실효성이 작습니다. 유지해도 큰 문제는 없지만 PDF 전용 CSS라면 제거해도 됩니다. - 테스트 문자열 일부가 mojibake(`?쒓? PDF`) 상태입니다. 테스트 의도는 CSS 주입 확인이라 통과하지만, 실제 “한글 글리프 보존” 검증으로는 부족합니다. 최소한 정상 한글 샘플(`한글 PDF 테스트`)을 넣는 편이 낫습니다. ## 4) scope-lock 조건 갱신안 | 항목 | 합의 제안 | |---|---| | 적용 루트 | 현재 워킹트리 `geulbeot-light(8th)_0206` 기준으로 진행. 이슈 본문의 `geulbeot_10th` 경로명은 실제 repo 경로로 치환 | | 영향 라우트 | `/download/pdf`만 기능 변경. HTML/HWP export는 건드리지 않음 | | 변환 진입점 | `app.py`는 지금처럼 `converters.html_to_pdf.render_pdf()`에 위임 | | PDF CSS 위치 | `static/css/pdf_print.css` 신설 또는 현재 helper CSS를 해당 파일로 분리. Python에는 폰트 face 생성/주입 로직만 남김 | | 폰트 위치 | `static/fonts/` 우선. `GEULBEOT_PDF_FONT_DIR`은 운영 override로 유지 | | 기본 폰트 | 안정성 우선이면 Noto Sans KR Regular/Bold 정적 파일. repo 크기 제약이 크면 Pretendard static/variable을 별도 검증 후 선택 | | 라이선스 | 폰트 파일과 같은 폴더 또는 `static/fonts/README`에 OFL/출처 명시 | | family 정책 | 기본은 `Noto Sans KR`/`Malgun Gothic` alias 매핑. `맑은 고딕`, `-윤고딕*` inline style 강제 치환은 별도 옵션 또는 후속 이슈로 분리 | | base_url | `HTML(..., base_url=str(app.root_path))` 유지. 폰트 URL은 `Path.as_uri()` 또는 `CSS(filename=...)` 기준으로 절대 해석되게 고정 | | 배포 의존성 | Railway/Nixpacks에서 WeasyPrint 시스템 의존성(Pango/Cairo/Fontconfig 등)은 이 이슈의 완료조건에 포함하는 편이 안전 | | 검증 | unit test + 샘플 HTML PDF 생성 + 가능하면 `pdffonts`/`pdftotext`로 한글 텍스트와 embedded/subset font 확인 | | 범위 제외 | `/assets` 하드코딩, HWPX/HWP 폰트 정책, 페이지 헤더/푸터/쪽번호는 별도 이슈 | ## 5) 100% 동의 여부 아직 `100% consensus reached`라고 보기는 어렵습니다. 이유는 현재 워킹트리에 이미 PDF helper/test가 들어와 있어 Claude #1의 초기 상태 전제가 일부 stale이고, 폰트를 repo에 동봉할지 배포 이미지에 설치할지, `static/css/pdf_print.css`를 실제 파일로 둘지 helper 내 CSS로 유지할지 결정이 남아 있기 때문입니다. 다만 기능 방향에는 동의합니다. 다음 라운드에서 아래 3가지만 확정되면 바로 구현 phase로 넘어갈 수 있습니다. 1. 폰트 파일을 repo의 `static/fonts/`에 동봉할지, 운영 환경 설치/`GEULBEOT_PDF_FONT_DIR`로 해결할지 2. `pdf_print.css`를 이슈 본문대로 실제 파일로 만들지, 현재 helper의 동적 CSS 문자열 구조를 유지할지 3. Railway/Nixpacks 시스템 의존성 추가를 이번 이슈 완료조건에 포함할지
Author
Owner

[Claude 실행 계획]

0) 합의 베이스라인과 미결정 항목에 대한 디폴트

Codex #1까지의 합의 + 본 라운드에서 다음 3개 미결정 항목은 아래 디폴트로 잠그고 진행한다. 이견 있으면 실행 전에 코멘트로 뒤집어달라.

미결정 본 계획에서의 디폴트 근거
D1. 폰트 동봉 vs 운영 설치 repo static/fonts/에 Noto Sans KR Regular/Bold 정적 OTF 동봉 (SIL OFL 1.1) Railway Nixpacks 빌더는 한글 폰트를 보장하지 않음. Linux/macOS/Windows 어디서나 즉시 재현 가능해야 이슈 목표("깨짐 해소")가 환경 무관해진다. GEULBEOT_PDF_FONT_DIR은 운영 override로 유지
D2. pdf_print.css 파일화 static/css/pdf_print.css 실제 파일로 신설. helper는 @font-face만 동적 생성, 정적 룰은 파일에서 로드 이슈 본문에 명시된 산출물. CSS 변경이 코드 리뷰/git diff에 그대로 잡혀야 디자인 변경 트래킹 가능
D3. 시스템 의존성 nixpacks.toml 신설하여 pango, cairo, fontconfig, harfbuzz apt 패키지 명시 (본 이슈 완료조건에 포함) 폰트 동봉만 해서는 WeasyPrint가 OS lib 미설치 시 import 단계에서 죽거나 임베딩 실패. 이슈 "PDF 변환 시 깨짐 해소"의 완료조건과 한 묶음이 되어야 의미가 있다

원본 이슈 의도와의 정합: 이슈 본문 경로(geulbeot_10th/...)는 본 워킹트리(geulbeot-light(8th)_0206)로 치환. 이슈 의도에서 벗어나는 부분 없음.

명시적 범위 외(별도 이슈로 분리):

  • app.py:292 D:\\for python\\... 하드코딩 /assets 라우트 — 결함이지만 PDF 폰트와 직교
  • 페이지 헤더/푸터/쪽번호
  • 맑은 고딕/-윤고딕* 등 inline style= 강제 override
  • HWP/HWPX export 폰트 정책

1) 변경 대상 파일 목록

신규 (Create)

  1. static/fonts/NotoSansKR-Regular.otf — Noto Sans KR Regular (Google Fonts 배포본)
  2. static/fonts/NotoSansKR-Bold.otf — Noto Sans KR Bold
  3. static/fonts/OFL.txt — SIL Open Font License 1.1 원문
  4. static/fonts/README.md — 폰트 출처/버전/라이선스 명시
  5. static/css/pdf_print.css — PDF 전용 정적 CSS (@page, 페이지 브레이크, 색상 모드, 타이포)
  6. nixpacks.toml — Railway 빌드 시 apt 패키지 명시

수정 (Modify)

  1. converters/html_to_pdf.py — @font-face만 동적 생성, 정적 룰은 pdf_print.css 로드로 위임. font_config/stylesheets 주입 경로 정리
  2. tests/test_html_to_pdf.py — 신규 파일 기반 CSS 로드와 한글 정상 문자열 검증 케이스 추가

비변경 (의도적)

  • app.py:271 — 이미 render_pdf(html_content, app.root_path)로 위임 중. 시그니처/호출부 유지
  • requirements.txtweasyprint==60.1 그대로

2) 각 파일별 변경 내용 (구체)

2-1. static/fonts/NotoSansKR-{Regular,Bold}.otf

  • 출처: https://github.com/notofonts/noto-cjkSans/OTF/Korean/NotoSansKR-Regular.otf, NotoSansKR-Bold.otf (정적 OTF, hinted)
  • 변형 폰트(NotoSansKR-VF.ttf)는 WeasyPrint 60.x의 variable axis 처리 호환 검증 부담이 있어 배제. 정적 2종으로 단순화
  • 합산 크기: ~10–12MB. repo 비대화 우려 시 gitattributes로 LFS 후보지만, 본 이슈에선 LFS 도입을 포함하지 않음(별도 이슈)

2-2. static/fonts/OFL.txt, README.md

  • OFL.txt: SIL OFL 1.1 원문 그대로
  • README.md: Family / 버전 또는 커밋 해시 / 라이선스(SIL OFL 1.1) / 출처 URL / "수정 금지, 재배포 시 동일 라이선스" 명시

2-3. static/css/pdf_print.css (신설)

정적 룰만 담는다. @font-face는 들어가지 않음(폰트 절대경로가 환경마다 다르므로 Python에서 주입):

@page {
    size: A4;
    margin: 20mm 18mm;
}

html {
    -webkit-print-color-adjust: exact;
    print-color-adjust: exact;
}

body {
    font-family: 'Noto Sans KR', 'Noto Sans KR PDF', 'Malgun Gothic', sans-serif;
    text-rendering: optimizeLegibility;
    line-height: 1.55;
}

table, figure, img,
.data-table, .lead-box, .bottom-box, .info-box, .strategy-item {
    break-inside: avoid;
    page-break-inside: avoid;
}

h1, h2, h3, h4, h5, h6, .section-title {
    break-after: avoid;
    page-break-after: avoid;
}

img { max-width: 100%; }

a { color: inherit; text-decoration: none; }

마진은 A4 20mm/18mm로 고정(이슈에서 별도 지정 없음, 통용 기본값). font-display: swap은 PDF에선 무의미하므로 제거.

2-4. nixpacks.toml (신설)

[phases.setup]
aptPkgs = [
    "libpango-1.0-0",
    "libpangoft2-1.0-0",
    "libharfbuzz0b",
    "libcairo2",
    "libffi-dev",
    "fontconfig"
]
  • WeasyPrint 60.1 시스템 의존성 공식 목록 기준. fontconfig 캐시는 컨테이너 빌드 시점에 폰트 디렉토리를 인덱싱
  • aptfile 대신 nixpacks.toml을 쓰는 이유: railway.json이 NIXPACKS 빌더를 명시하고 있어 일관성 유지

2-5. converters/html_to_pdf.py (수정)

핵심 변경 3가지:

(a) 정적 CSS 분리: 현재 build_pdf_stylesheet()가 반환하는 문자열에서 정적 룰을 제거. @font-face만 남김. 정적 CSS는 static/css/pdf_print.css 파일을 그대로 사용.

(b) WeasyPrint 주입 경로 변경: render_pdfstylesheets=[CSS(string=font_faces, font_config=fc), CSS(filename=pdf_print_css_path, font_config=fc)]로 두 개를 명시 주입. prepare_html_for_pdf<style> 인라인 삽입을 더 이상 하지 않고 Google Fonts @import 제거만 담당:

def render_pdf(html_content: str, root_path: str | os.PathLike[str]) -> bytes:
    from weasyprint import HTML, CSS
    from weasyprint.text.fonts import FontConfiguration

    fc = _get_font_config()  # module-level cache
    cleaned = strip_remote_google_fonts(html_content)
    font_face_css = build_font_face_css(root_path)
    static_css_path = Path(root_path) / "static" / "css" / "pdf_print.css"

    stylesheets = [CSS(string=font_face_css, font_config=fc)]
    if static_css_path.exists():
        stylesheets.append(CSS(filename=str(static_css_path), font_config=fc))

    return HTML(string=cleaned, base_url=str(root_path)).write_pdf(
        stylesheets=stylesheets, font_config=fc,
    )

(c) FontConfiguration 캐싱: 모듈 전역 _FONT_CONFIG = None + _get_font_config()로 1회만 생성. 매 요청 폰트 파싱 비용 제거.

기존 함수 prepare_html_for_pdf는 호환을 위해 남기되 내부적으로 strip_remote_google_fonts만 호출하도록 축소. 테스트 호환을 위해 시그니처 유지.

(d) 유지: discover_korean_fonts, _candidate_font_dirs, GEULBEOT_PDF_FONT_DIR 우선순위는 그대로.

2-6. tests/test_html_to_pdf.py (수정/추가)

  • 기존 test_discovers_project_font_directory: 유지
  • 기존 test_prepare_html_removes_remote_google_font_and_injects_pdf_css: geulbeot-pdf-style 인라인 삽입 단언을 제거하고, Google Fonts @import 제거만 단언. 검증 문자열은 정상 한글(한글 PDF 테스트)로 교정 (mojibake 정리)
  • 신규 test_build_font_face_css_uses_local_font_path: 임시 디렉토리에 fake font를 두고 build_font_face_css(root) 반환값이 @font-facefile:// URL을 포함하는지 검증
  • 신규 test_render_pdf_attaches_pdf_print_stylesheet: WeasyPrint를 monkeypatch하여 HTML(...).write_pdf(stylesheets=...)로 전달되는 stylesheets 인자 개수와 pdf_print.css 경로 포함 여부만 단언(실제 PDF 바이트는 생성하지 않음 — 시스템 의존성 회피)

3) 테스트 방법

3-1. 단위 테스트

python -m unittest discover -s tests -p 'test_*.py'
  • 통과 기대치: 4개 (기존 2개 + 신규 2개)

3-2. 로컬 통합 테스트 (Windows 개발자 머신)

set GEULBEOT_PDF_FONT_DIR=
python app.py
# 다른 셸에서
curl -X POST --data-urlencode "html@00.test/A 결과.html" http://localhost:5000/download/pdf -o out.pdf
  • 합격 기준 (3중):
    1. out.pdf가 200 응답으로 받아진다
    2. pdffonts out.pdf 출력에 임베디드 NotoSansKR-Regular (또는 NotoSansKR-Bold) 가 emb=yes sub=yes로 1개 이상 출현
    3. pdftotext -layout out.pdf - 출력에 원문 한글 문자열이 mojibake 없이 보존

3-3. 배포 환경 검증 (Railway preview)

  • preview 환경에 push → /health 200 확인 → /download/pdf 같은 페이로드로 동일 3중 검증
  • preview 환경에서 GEULBEOT_PDF_FONT_DIR을 비워도(=repo 동봉 폰트만 사용해도) 합격해야 함

3-4. 회귀 체크

  • /download/html 응답 바이트 길이/내용이 변경 전후 동일한지 byte-diff
  • /download/hwp 또는 hwpx export 라우트 응답 정상(500 없음)

4) 롤백 조건

다음 중 하나라도 발생 시 즉시 롤백(=PR revert 또는 직전 커밋으로 reset):

# 조건 검출 방법
R1 단위 테스트 4개 중 1개라도 실패 CI / python -m unittest discover
R2 로컬 또는 preview에서 /download/pdf가 500 또는 501 응답 curl/HTTP 로그
R3 pdffonts 결과에 임베디드 한글 폰트가 0개 (= 시스템 폰트로 다시 폴백됨) 3-2의 step 2
R4 한글 문자열이 PDF 본문 텍스트 추출에서 ?, , mojibake로 나타남 3-2의 step 3
R5 Railway 빌드가 nixpacks 단계에서 apt 패키지 미해석으로 실패 Railway build log
R6 /download/html, hwpx export 등 인접 라우트에 의도치 않은 회귀 3-4
R7 repo 크기 증가가 100MB GitHub push limit 또는 Gitea 운영 한도를 위협 (현 추정 +10–12MB라 발생 가능성 낮음) git count-objects -vH

롤백 절차:

  1. main 브랜치 git revert <merge-commit> (단일 PR로 묶을 것)
  2. Railway가 새 커밋 자동 재배포
  3. /health 200 + /download/pdf가 변경 이전 동작(즉 "인라인 <style>만 신뢰")으로 복귀하는지 확인

부분 롤백 가능 지점:

  • nixpacks.toml만 문제일 경우 → 해당 파일만 revert (R5)
  • pdf_print.css 룰만 문제일 경우 → 해당 파일만 revert해도 helper는 stylesheet 없이 @font-face만으로 동작(폰트 임베딩은 유지) (R6)

5) 진행 가능 신호

위 디폴트 D1/D2/D3에 이견이 없으면 본 계획 그대로 실행 phase로 진입한다. 이견(예: 폰트는 동봉 말고 배포 이미지 설치로 가자, nixpacks는 별도 이슈로 빼자)이 있으면 코멘트로 표기 부탁.

[Claude 실행 계획] ## 0) 합의 베이스라인과 미결정 항목에 대한 디폴트 Codex #1까지의 합의 + 본 라운드에서 다음 3개 미결정 항목은 아래 디폴트로 잠그고 진행한다. 이견 있으면 실행 전에 코멘트로 뒤집어달라. | 미결정 | 본 계획에서의 디폴트 | 근거 | |---|---|---| | D1. 폰트 동봉 vs 운영 설치 | repo `static/fonts/`에 Noto Sans KR Regular/Bold 정적 OTF 동봉 (SIL OFL 1.1) | Railway Nixpacks 빌더는 한글 폰트를 보장하지 않음. Linux/macOS/Windows 어디서나 즉시 재현 가능해야 이슈 목표("깨짐 해소")가 환경 무관해진다. `GEULBEOT_PDF_FONT_DIR`은 운영 override로 유지 | | D2. `pdf_print.css` 파일화 | `static/css/pdf_print.css` **실제 파일로 신설**. helper는 @font-face만 동적 생성, 정적 룰은 파일에서 로드 | 이슈 본문에 명시된 산출물. CSS 변경이 코드 리뷰/git diff에 그대로 잡혀야 디자인 변경 트래킹 가능 | | D3. 시스템 의존성 | `nixpacks.toml` 신설하여 `pango`, `cairo`, `fontconfig`, `harfbuzz` apt 패키지 명시 (본 이슈 완료조건에 포함) | 폰트 동봉만 해서는 WeasyPrint가 OS lib 미설치 시 import 단계에서 죽거나 임베딩 실패. 이슈 "PDF 변환 시 깨짐 해소"의 완료조건과 한 묶음이 되어야 의미가 있다 | 원본 이슈 의도와의 정합: 이슈 본문 경로(`geulbeot_10th/...`)는 본 워킹트리(`geulbeot-light(8th)_0206`)로 치환. **이슈 의도에서 벗어나는 부분 없음.** 명시적 범위 외(별도 이슈로 분리): - `app.py:292` `D:\\for python\\...` 하드코딩 `/assets` 라우트 — 결함이지만 PDF 폰트와 직교 - 페이지 헤더/푸터/쪽번호 - `맑은 고딕`/`-윤고딕*` 등 inline `style=` 강제 override - HWP/HWPX export 폰트 정책 --- ## 1) 변경 대상 파일 목록 ### 신규 (Create) 1. `static/fonts/NotoSansKR-Regular.otf` — Noto Sans KR Regular (Google Fonts 배포본) 2. `static/fonts/NotoSansKR-Bold.otf` — Noto Sans KR Bold 3. `static/fonts/OFL.txt` — SIL Open Font License 1.1 원문 4. `static/fonts/README.md` — 폰트 출처/버전/라이선스 명시 5. `static/css/pdf_print.css` — PDF 전용 정적 CSS (@page, 페이지 브레이크, 색상 모드, 타이포) 6. `nixpacks.toml` — Railway 빌드 시 apt 패키지 명시 ### 수정 (Modify) 7. `converters/html_to_pdf.py` — @font-face만 동적 생성, 정적 룰은 `pdf_print.css` 로드로 위임. `font_config`/`stylesheets` 주입 경로 정리 8. `tests/test_html_to_pdf.py` — 신규 파일 기반 CSS 로드와 한글 정상 문자열 검증 케이스 추가 ### 비변경 (의도적) - `app.py:271` — 이미 `render_pdf(html_content, app.root_path)`로 위임 중. 시그니처/호출부 유지 - `requirements.txt` — `weasyprint==60.1` 그대로 --- ## 2) 각 파일별 변경 내용 (구체) ### 2-1. `static/fonts/NotoSansKR-{Regular,Bold}.otf` - 출처: `https://github.com/notofonts/noto-cjk` 의 `Sans/OTF/Korean/NotoSansKR-Regular.otf`, `NotoSansKR-Bold.otf` (정적 OTF, hinted) - 변형 폰트(`NotoSansKR-VF.ttf`)는 WeasyPrint 60.x의 variable axis 처리 호환 검증 부담이 있어 배제. 정적 2종으로 단순화 - 합산 크기: ~10–12MB. repo 비대화 우려 시 `gitattributes`로 LFS 후보지만, 본 이슈에선 LFS 도입을 포함하지 않음(별도 이슈) ### 2-2. `static/fonts/OFL.txt`, `README.md` - `OFL.txt`: SIL OFL 1.1 원문 그대로 - `README.md`: Family / 버전 또는 커밋 해시 / 라이선스(SIL OFL 1.1) / 출처 URL / "수정 금지, 재배포 시 동일 라이선스" 명시 ### 2-3. `static/css/pdf_print.css` (신설) 정적 룰만 담는다. `@font-face`는 들어가지 않음(폰트 절대경로가 환경마다 다르므로 Python에서 주입): ```css @page { size: A4; margin: 20mm 18mm; } html { -webkit-print-color-adjust: exact; print-color-adjust: exact; } body { font-family: 'Noto Sans KR', 'Noto Sans KR PDF', 'Malgun Gothic', sans-serif; text-rendering: optimizeLegibility; line-height: 1.55; } table, figure, img, .data-table, .lead-box, .bottom-box, .info-box, .strategy-item { break-inside: avoid; page-break-inside: avoid; } h1, h2, h3, h4, h5, h6, .section-title { break-after: avoid; page-break-after: avoid; } img { max-width: 100%; } a { color: inherit; text-decoration: none; } ``` 마진은 A4 20mm/18mm로 고정(이슈에서 별도 지정 없음, 통용 기본값). `font-display: swap`은 PDF에선 무의미하므로 제거. ### 2-4. `nixpacks.toml` (신설) ```toml [phases.setup] aptPkgs = [ "libpango-1.0-0", "libpangoft2-1.0-0", "libharfbuzz0b", "libcairo2", "libffi-dev", "fontconfig" ] ``` - WeasyPrint 60.1 시스템 의존성 공식 목록 기준. fontconfig 캐시는 컨테이너 빌드 시점에 폰트 디렉토리를 인덱싱 - `aptfile` 대신 `nixpacks.toml`을 쓰는 이유: `railway.json`이 NIXPACKS 빌더를 명시하고 있어 일관성 유지 ### 2-5. `converters/html_to_pdf.py` (수정) 핵심 변경 3가지: (a) **정적 CSS 분리**: 현재 `build_pdf_stylesheet()`가 반환하는 문자열에서 정적 룰을 제거. @font-face만 남김. 정적 CSS는 `static/css/pdf_print.css` 파일을 그대로 사용. (b) **WeasyPrint 주입 경로 변경**: `render_pdf`가 `stylesheets=[CSS(string=font_faces, font_config=fc), CSS(filename=pdf_print_css_path, font_config=fc)]`로 두 개를 명시 주입. `prepare_html_for_pdf`는 `<style>` 인라인 삽입을 더 이상 하지 않고 Google Fonts `@import` 제거만 담당: ```python def render_pdf(html_content: str, root_path: str | os.PathLike[str]) -> bytes: from weasyprint import HTML, CSS from weasyprint.text.fonts import FontConfiguration fc = _get_font_config() # module-level cache cleaned = strip_remote_google_fonts(html_content) font_face_css = build_font_face_css(root_path) static_css_path = Path(root_path) / "static" / "css" / "pdf_print.css" stylesheets = [CSS(string=font_face_css, font_config=fc)] if static_css_path.exists(): stylesheets.append(CSS(filename=str(static_css_path), font_config=fc)) return HTML(string=cleaned, base_url=str(root_path)).write_pdf( stylesheets=stylesheets, font_config=fc, ) ``` (c) **`FontConfiguration` 캐싱**: 모듈 전역 `_FONT_CONFIG = None` + `_get_font_config()`로 1회만 생성. 매 요청 폰트 파싱 비용 제거. 기존 함수 `prepare_html_for_pdf`는 호환을 위해 남기되 내부적으로 `strip_remote_google_fonts`만 호출하도록 축소. 테스트 호환을 위해 시그니처 유지. (d) **유지**: `discover_korean_fonts`, `_candidate_font_dirs`, `GEULBEOT_PDF_FONT_DIR` 우선순위는 그대로. ### 2-6. `tests/test_html_to_pdf.py` (수정/추가) - 기존 `test_discovers_project_font_directory`: 유지 - 기존 `test_prepare_html_removes_remote_google_font_and_injects_pdf_css`: `geulbeot-pdf-style` 인라인 삽입 단언을 제거하고, Google Fonts `@import` 제거만 단언. 검증 문자열은 정상 한글(`한글 PDF 테스트`)로 교정 (mojibake 정리) - **신규** `test_build_font_face_css_uses_local_font_path`: 임시 디렉토리에 fake font를 두고 `build_font_face_css(root)` 반환값이 `@font-face`와 `file://` URL을 포함하는지 검증 - **신규** `test_render_pdf_attaches_pdf_print_stylesheet`: WeasyPrint를 monkeypatch하여 `HTML(...).write_pdf(stylesheets=...)`로 전달되는 stylesheets 인자 개수와 `pdf_print.css` 경로 포함 여부만 단언(실제 PDF 바이트는 생성하지 않음 — 시스템 의존성 회피) --- ## 3) 테스트 방법 ### 3-1. 단위 테스트 ``` python -m unittest discover -s tests -p 'test_*.py' ``` - 통과 기대치: 4개 (기존 2개 + 신규 2개) ### 3-2. 로컬 통합 테스트 (Windows 개발자 머신) ``` set GEULBEOT_PDF_FONT_DIR= python app.py # 다른 셸에서 curl -X POST --data-urlencode "html@00.test/A 결과.html" http://localhost:5000/download/pdf -o out.pdf ``` - 합격 기준 (3중): 1. `out.pdf`가 200 응답으로 받아진다 2. `pdffonts out.pdf` 출력에 임베디드 `NotoSansKR-Regular` (또는 `NotoSansKR-Bold`) 가 `emb=yes sub=yes`로 1개 이상 출현 3. `pdftotext -layout out.pdf -` 출력에 원문 한글 문자열이 mojibake 없이 보존 ### 3-3. 배포 환경 검증 (Railway preview) - preview 환경에 push → `/health` 200 확인 → `/download/pdf` 같은 페이로드로 동일 3중 검증 - preview 환경에서 `GEULBEOT_PDF_FONT_DIR`을 비워도(=repo 동봉 폰트만 사용해도) 합격해야 함 ### 3-4. 회귀 체크 - `/download/html` 응답 바이트 길이/내용이 변경 전후 동일한지 byte-diff - `/download/hwp` 또는 hwpx export 라우트 응답 정상(500 없음) --- ## 4) 롤백 조건 다음 중 하나라도 발생 시 즉시 롤백(=PR revert 또는 직전 커밋으로 reset): | # | 조건 | 검출 방법 | |---|------|----------| | R1 | 단위 테스트 4개 중 1개라도 실패 | CI / `python -m unittest discover` | | R2 | 로컬 또는 preview에서 `/download/pdf`가 500 또는 501 응답 | curl/HTTP 로그 | | R3 | `pdffonts` 결과에 임베디드 한글 폰트가 0개 (= 시스템 폰트로 다시 폴백됨) | 3-2의 step 2 | | R4 | 한글 문자열이 PDF 본문 텍스트 추출에서 `?`, `□`, mojibake로 나타남 | 3-2의 step 3 | | R5 | Railway 빌드가 nixpacks 단계에서 apt 패키지 미해석으로 실패 | Railway build log | | R6 | `/download/html`, hwpx export 등 인접 라우트에 의도치 않은 회귀 | 3-4 | | R7 | repo 크기 증가가 100MB GitHub push limit 또는 Gitea 운영 한도를 위협 (현 추정 +10–12MB라 발생 가능성 낮음) | `git count-objects -vH` | 롤백 절차: 1. main 브랜치 `git revert <merge-commit>` (단일 PR로 묶을 것) 2. Railway가 새 커밋 자동 재배포 3. `/health` 200 + `/download/pdf`가 변경 이전 동작(즉 "인라인 `<style>`만 신뢰")으로 복귀하는지 확인 부분 롤백 가능 지점: - `nixpacks.toml`만 문제일 경우 → 해당 파일만 revert (R5) - `pdf_print.css` 룰만 문제일 경우 → 해당 파일만 revert해도 helper는 stylesheet 없이 @font-face만으로 동작(폰트 임베딩은 유지) (R6) --- ## 5) 진행 가능 신호 위 디폴트 D1/D2/D3에 이견이 없으면 본 계획 그대로 실행 phase로 진입한다. 이견(예: 폰트는 동봉 말고 배포 이미지 설치로 가자, nixpacks는 별도 이슈로 빼자)이 있으면 코멘트로 표기 부탁.
Author
Owner

[Codex 계획 검토 #2]

1) 원본 이슈 범위와의 정합성

Claude 실행 계획은 원본 이슈의 핵심 범위와 거의 일치합니다.

  • 서버 PDF 변환 시 한글 폰트 누락/대체 문제 해결
  • static/css/pdf_print.css 신설로 PDF 전용 스타일 분리
  • PDF 변환 경로에서 WeasyPrint FontConfiguration/CSS/base_url를 명시적으로 주입
  • Railway/Nixpacks 환경의 WeasyPrint 시스템 의존성까지 완료조건에 포함

이슈 본문의 geulbeot_10th/... 경로를 현재 워킹트리 기준 app.py, converters/html_to_pdf.py, static/...로 치환한다는 전제도 타당합니다. /download/pdf만 건드리고 HWP/HWPX export, /assets 하드코딩, 페이지 헤더/푸터를 분리하는 범위 제한도 적절합니다.

2) 누락/수정 필요 사항

한 가지는 실행 전에 반드시 고쳐야 합니다.

  • 계획의 폰트 출처/파일명: https://github.com/notofonts/noto-cjkSans/OTF/Korean/NotoSansKR-Regular.otf 경로는 존재하지 않습니다. 확인 결과 NotoSansKR-Regular.otf raw URL은 404이고, 실제 Korean OTF 파일명은 NotoSansCJKkr-Regular.otf, NotoSansCJKkr-Bold.otf입니다.
  • 따라서 선택지는 둘 중 하나로 잠가야 합니다.
    • notofonts/noto-cjk에서 NotoSansCJKkr-Regular.otf, NotoSansCJKkr-Bold.otf를 그대로 동봉한다.
    • 동봉 파일명을 NotoSansKR-Regular.otf, NotoSansKR-Bold.otf로 리네임하되 README에 원본 파일명이 NotoSansCJKkr-*였음을 명시한다.
  • 현재 discover_korean_fonts()NotoSansCJKkr-Regular.otf/NotoSansCJKkr-Bold.otf도 이미 후보에 포함하므로, 리네임 없이 원본 파일명으로 동봉하는 쪽이 가장 단순합니다.

그 외 보완 권장 사항입니다.

  • build_pdf_stylesheet()build_font_face_css()로 바꾸는 경우, 기존 테스트/호출부 호환을 위해 alias를 남기거나 테스트를 한 번에 정리해야 합니다.
  • prepare_html_for_pdf()가 더 이상 스타일을 주입하지 않도록 축소되면, 함수명/docstring도 “Google Fonts 원격 import 제거” 역할에 맞게 바꾸는 편이 혼동이 적습니다. 외부 호출 가능성을 고려하면 기존 이름은 유지하되 내부 주석을 명확히 하는 정도도 괜찮습니다.
  • static/css/pdf_print.css가 없을 때 조용히 넘어가는 fallback은 개발 중에는 편하지만, 이번 이슈의 명시 산출물이므로 테스트에서는 파일 존재와 CSS(filename=...) 전달을 반드시 검증하는 편이 맞습니다.

3) 리스크 / 사이드 이펙트

  • repo 크기 증가는 감수 가능한 범위로 보입니다. 다만 NotoSansCJKkr OTF Regular/Bold는 파일 크기가 작지 않으므로 PR에서 실제 증가량을 확인해야 합니다.
  • nixpacks.toml의 apt 패키지명은 방향은 맞지만, Railway/Nixpacks base image에서 패키지명이 모두 해석되는지 preview build로 확인해야 합니다. 특히 이 파일 추가는 코드 테스트만으로 검증되지 않습니다.
  • FontConfiguration 캐싱은 적절합니다. 다만 테스트에서 WeasyPrint를 monkeypatch할 때 import 위치가 render_pdf() 내부라는 점을 반영해야 합니다.
  • inline style="font-family: ..."에 대한 강제 override를 이번 범위에서 제외하는 결정은 동의합니다. 다만 이 경우 “모든 한글 family를 무조건 치환”이 아니라 “Noto/Malgun 계열 및 일반 body CSS 중심 보장”이라는 한계를 구현 코멘트나 후속 이슈에 남기는 것이 좋습니다.
  • PDF 실제 품질 검증은 단위 테스트만으로 부족합니다. 계획의 pdffonts/pdftotext 확인 기준은 유지해야 합니다.

4) 합의 여부

아직 100% consensus reached라고 보기는 어렵습니다. 이유는 폰트 다운로드 출처/파일명에 실제 404가 나는 구체 오류가 있고, 이 상태로 실행하면 신규 폰트 동봉 단계에서 실패하거나 README/코드와 ���출물이 어긋날 수 있기 때문입니다.

다만 위 폰트 파일명만 NotoSansCJKkr-Regular.otf/NotoSansCJKkr-Bold.otf 기준으로 수정하면, 나머지 실행 계획은 원본 이슈 범위에 부합하며 구현 phase로 진행해도 됩니다.

[Codex 계획 검토 #2] ## 1) 원본 이슈 범위와의 정합성 Claude 실행 계획은 원본 이슈의 핵심 범위와 거의 일치합니다. - 서버 PDF 변환 시 한글 폰트 누락/대체 문제 해결 - `static/css/pdf_print.css` 신설로 PDF 전용 스타일 분리 - PDF 변환 경로에서 WeasyPrint `FontConfiguration`/`CSS`/`base_url`를 명시적으로 주입 - Railway/Nixpacks 환경의 WeasyPrint 시스템 의존성까지 완료조건에 포함 이슈 본문의 `geulbeot_10th/...` 경로를 현재 워킹트리 기준 `app.py`, `converters/html_to_pdf.py`, `static/...`로 치환한다는 전제도 타당합니다. `/download/pdf`만 건드리고 HWP/HWPX export, `/assets` 하드코딩, 페이지 헤더/푸터를 분리하는 범위 제한도 적절합니다. ## 2) 누락/수정 필요 사항 한 가지는 실행 전에 반드시 고쳐야 합니다. - 계획의 폰트 출처/파일명: `https://github.com/notofonts/noto-cjk`의 `Sans/OTF/Korean/NotoSansKR-Regular.otf` 경로는 존재하지 않습니다. 확인 결과 `NotoSansKR-Regular.otf` raw URL은 404이고, 실제 Korean OTF 파일명은 `NotoSansCJKkr-Regular.otf`, `NotoSansCJKkr-Bold.otf`입니다. - 따라서 선택지는 둘 중 하나로 잠가야 합니다. - notofonts/noto-cjk에서 `NotoSansCJKkr-Regular.otf`, `NotoSansCJKkr-Bold.otf`를 그대로 동봉한다. - 동봉 파일명을 `NotoSansKR-Regular.otf`, `NotoSansKR-Bold.otf`로 리네임하되 README에 원본 파일명이 `NotoSansCJKkr-*`였음을 명시한다. - 현재 `discover_korean_fonts()`는 `NotoSansCJKkr-Regular.otf`/`NotoSansCJKkr-Bold.otf`도 이미 후보에 포함하므로, 리네임 없이 원본 파일명으로 동봉하는 쪽이 가장 단순합니다. 그 외 보완 권장 사항입니다. - `build_pdf_stylesheet()`를 `build_font_face_css()`로 바꾸는 경우, 기존 테스트/호출부 호환을 위해 alias를 남기거나 테스트를 한 번에 정리해야 합니다. - `prepare_html_for_pdf()`가 더 이상 스타일을 주입하지 않도록 축소되면, 함수명/docstring도 “Google Fonts 원격 import 제거” 역할에 맞게 바꾸는 편이 혼동이 적습니다. 외부 호출 가능성을 고려하면 기존 이름은 유지하되 내부 주석을 명확히 하는 정도도 괜찮습니다. - `static/css/pdf_print.css`가 없을 때 조용히 넘어가는 fallback은 개발 중에는 편하지만, 이번 이슈의 명시 산출물이므로 테스트에서는 파일 존재와 `CSS(filename=...)` 전달을 반드시 검증하는 편이 맞습니다. ## 3) 리스크 / 사이드 이펙트 - repo 크기 증가는 감수 가능한 범위로 보입니다. 다만 `NotoSansCJKkr` OTF Regular/Bold는 파일 크기가 작지 않으므로 PR에서 실제 증가량을 확인해야 합니다. - `nixpacks.toml`의 apt 패키지명은 방향은 맞지만, Railway/Nixpacks base image에서 패키지명이 모두 해석되는지 preview build로 확인해야 합니다. 특히 이 파일 추가는 코드 테스트만으로 검증되지 않습니다. - `FontConfiguration` 캐싱은 적절합니다. 다만 테스트에서 WeasyPrint를 monkeypatch할 때 import 위치가 `render_pdf()` 내부라는 점을 반영해야 합니다. - inline `style="font-family: ..."`에 대한 강제 override를 이번 범위에서 제외하는 결정은 동의합니다. 다만 이 경우 “모든 한글 family를 무조건 치환”이 아니라 “Noto/Malgun 계열 및 일반 body CSS 중심 보장”이라는 한계를 구현 코멘트나 후속 이슈에 남기는 것이 좋습니다. - PDF 실제 품질 검증은 단위 테스트만으로 부족합니다. 계획의 `pdffonts`/`pdftotext` 확인 기준은 유지해야 합니다. ## 4) 합의 여부 아직 `100% consensus reached`라고 보기는 어렵습니다. 이유는 폰트 다운로드 출처/파일명에 실제 404가 나는 구체 오류가 있고, 이 상태로 실행하면 신규 폰트 동봉 단계에서 실패하거나 README/코드와 ���출물이 어긋날 수 있기 때문입니다. 다만 위 폰트 파일명만 `NotoSansCJKkr-Regular.otf`/`NotoSansCJKkr-Bold.otf` 기준으로 수정하면, 나머지 실행 계획은 원본 이슈 범위에 부합하며 구현 phase로 진행해도 됩니다.
Author
Owner

[Claude #3]

1) ?? ?? ?? ???

?? ?? ??: ?? PDF ?? ? ?? ?? ??/?? ?? + ??? ?? ??? ??. 4? ???(pdf_print.css, app.py ?? ??, assets/fonts/, ??? ??? ???). Round #2??? ??? ????, ?? ???? ??? ?? ??. ??? ?? ?? ?? 4? ??(/assets ????, ??/??/???, ??? style= family ?? override, HWP/HWPX ??)? ??? ??.

2) Codex #2 ?? ?? ? ?? ??

A. ?? ??/??? 404 (??? ? ?? + ?? ??)

?? HEAD ?? ??:

ERR 404           | NotoSansKR-Regular.otf       (notofonts/noto-cjk)
200  16,433,112 B | NotoSansCJKkr-Regular.otf
200  16,997,996 B | NotoSansCJKkr-Bold.otf

? ??. ?? ???? NotoSansCJKkr-Regular.otf / NotoSansCJKkr-Bold.otf ?? ??? ??? ????. ??? ?? ???.

??: converters/html_to_pdf.py:90,94? discover_korean_fonts()? ?? NotoSansCJKkr-Regular.otf/NotoSansCJKkr-Bold.otf? ?? ???? ???? ??, ?? ????? ?? ?? ????? ?? ??? 0???.

repo ?? ??? ??: ? ?? ??(+10-12MB)? ???. ?? +~33MB. 100MB GitHub push limit? Gitea ?? ???? ??? ?? ??? R7 ?? ??? ?? ??? ??. LFS ??? ? ?? ?? ? ??.

B. build_pdf_stylesheet ? build_font_face_css ??? ??

?? (alias ?? ?? ???? ??). ?? helper?? ?? import ??? grep?? ??, repo ? ???? prepare_html_for_pdf ? 1? + ??????? ?? ???? ? ?? ????. alias ??? ??.

C. prepare_html_for_pdf ??/docstring

Codex ?? ?? (??? ??, docstring? ??). ?? ????? app.py? ?? ????? import ???? ??? ??. docstring? ?? ??? ??:

Strip Google Fonts @import from the generated document so that WeasyPrint does not attempt remote fetches during PDF rendering. HTML markup is otherwise returned unchanged; font face and print CSS injection happens via render_pdf()'s stylesheets= argument.

<style id="geulbeot-pdf-style"> ??? ?? ??? ?? (Codex? ?? "? ?? ???? ???? ??"? ??).

D. pdf_print.css ?? ?? ?? ??

??. ? Round #2? test_render_pdf_attaches_pdf_print_stylesheet?? fallback("?? ??? ??? ??") ??? ???? ??? ??? ???? ??? ??? ????. ?? ?? if static_css_path.exists(): ??? ????, ?? ? FileNotFoundError? ???? ?? ??. ?? ?? ?????? silent fallback? ???.

self.assertTrue(static_css_path.exists(), 'static/css/pdf_print.css must exist')
# WeasyPrint monkeypatch?? captured CSS(filename=...) ???
# ??? ? ??? ????? ??

E. WeasyPrint monkeypatch import ?? (??)

?? + ?? ?? ??: from weasyprint import HTML, CSS, from weasyprint.text.fonts import FontConfiguration? ?? ???? hoist. ?? render_pdf() ?? ?? import???, app.py ?? ??? from converters.html_to_pdf import render_pdf? ?? ????? import ?? ?? ??? ?? (?? ?? lazy). ?? ???? ??? unittest.mock.patch('converters.html_to_pdf.HTML')? ????? ??.

F. ??? style= ?? ??

??. render_pdf() ?? 1? ???? ??? ???:

# Family aliases cover 'Noto Sans KR' / 'Malgun Gothic' family-level CSS.
# Element-level inline style="font-family: ..." (e.g. HWPX-derived
# '?? ??', '-???140') is intentionally not overridden in this scope;
# see follow-up issue for inline-style normalization.

G. nixpacks ???? preview build ??

??. R5 ??? ??. ?? ? ?? ???? ?? ????? Railway preview build ???? apt ??? ?? ?? ??? ?? ???? ????. preview build ?? ? ???? ??? pango1.0-tools, libcairo2-dev ??? ???? ? ? ???, ? ???? ?? ??.

3) ?? ????

(??) - ?? ???? ?? URL/??? ????? ??. ??? ?? ? ?? import ??? ? ? ? grep?? ?? ??? ?? phase ??.

4) Scope-lock ?? (Round #3 ??)

# ?? ???
L1 ?? ?? static/fonts/NotoSansCJKkr-Regular.otf (16.4MB) + static/fonts/NotoSansCJKkr-Bold.otf (17.0MB), ?? ??? ??
L2 ?? ?? https://github.com/notofonts/noto-cjk/raw/main/Sans/OTF/Korean/
L3 ???? ?? static/fonts/OFL.txt + static/fonts/README.md(??/????/SIL OFL 1.1 ??)
L4 ?? PDF CSS static/css/pdf_print.css ?? ?? ?? (?? ???, silent fallback ??)
L5 helper ??? build_pdf_stylesheet ? build_font_face_css (alias ??, ?? ??)
L6 prepare_html_for_pdf ?? ??, body? Google Fonts @import ??? ??
L7 ?? ?? render_pdf? stylesheets=[CSS(string=font_face_css), CSS(filename=pdf_print_css_path)] + FontConfiguration + base_url=str(root_path)
L8 FontConfiguration ?? ?? ?? _FONT_CONFIG 1? ??
L9 WeasyPrint import ?? ?? hoist (HTML, CSS, FontConfiguration)
L10 nixpacks nixpacks.toml ??, apt ??? libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libcairo2 libffi-dev fontconfig
L11 @page ?? A4 20mm 18mm
L12 family override ??? style= ???? ??. 1? ???? ?? ?? (?? ?? ??)
L13 ?? ??? /download/pdf?. HWP/HTML/HWPX ???
L14 ??? unit 4?(?? 2 + ?? 2: font-face URL + pdf_print.css attach). ?? ?? ???? mojibake ??. preview build?? pdffonts/pdftotext 3? ??
L15 ?? R7 ?? repo +~33MB ?? ??

5) ??

Codex #2? ??? 1? ???(?? ??? 404)? ?? ? L1/L2? ???, ?? 6? ??(B-G)? ? ????? ?? ??/????. ?? ?? ??. ? ??? ??? ?? phase? ????.

FINAL_CONSENSUS: YES

[Claude #3] ## 1) ?? ?? ?? ??? ?? ?? ??: ?? PDF ?? ? ?? ?? ??/?? ?? + ??? ?? ??? ??. 4? ???(`pdf_print.css`, `app.py` ?? ??, `assets/fonts/`, ??? ??? ???). Round #2??? ??? ????, **?? ???? ??? ?? ??**. ??? ?? ?? ?? 4? ??(`/assets` ????, ??/??/???, ??? `style=` family ?? override, HWP/HWPX ??)? ??? ??. ## 2) Codex #2 ?? ?? ? ?? ?? ### A. ?? ??/??? 404 (??? ? ?? + ?? ??) ?? HEAD ?? ??: ``` ERR 404 | NotoSansKR-Regular.otf (notofonts/noto-cjk) 200 16,433,112 B | NotoSansCJKkr-Regular.otf 200 16,997,996 B | NotoSansCJKkr-Bold.otf ``` ? **??**. ?? ???? **`NotoSansCJKkr-Regular.otf` / `NotoSansCJKkr-Bold.otf` ?? ??? ???** ????. ??? ?? ???. ??: `converters/html_to_pdf.py:90,94`? `discover_korean_fonts()`? ?? `NotoSansCJKkr-Regular.otf`/`NotoSansCJKkr-Bold.otf`? ?? ???? ???? ??, ?? ????? ?? ?? ????? ?? ??? 0???. repo ?? ??? ??: ? ?? ??(+10-12MB)? ???. **?? +~33MB**. 100MB GitHub push limit? Gitea ?? ???? ??? ?? ??? R7 ?? ??? ?? ??? ??. LFS ??? ? ?? ?? ? ??. ### B. `build_pdf_stylesheet` ? `build_font_face_css` ??? ?? **?? (alias ?? ?? ???? ??)**. ?? helper?? ?? import ??? grep?? ??, repo ? ???? `prepare_html_for_pdf` ? 1? + ??????? ?? ???? ? ?? ????. alias ??? ??. ### C. `prepare_html_for_pdf` ??/docstring **Codex ?? ?? (??? ??, docstring? ??)**. ?? ????? `app.py`? ?? ????? import ???? ??? ??. docstring? ?? ??? ??: > Strip Google Fonts `@import` from the generated document so that WeasyPrint does not attempt remote fetches during PDF rendering. HTML markup is otherwise returned unchanged; font face and print CSS injection happens via `render_pdf()`'s `stylesheets=` argument. `<style id="geulbeot-pdf-style">` ??? ?? ??? ?? (Codex? ?? "? ?? ???? ???? ??"? ??). ### D. `pdf_print.css` ?? ?? ?? ?? **??**. ? Round #2? `test_render_pdf_attaches_pdf_print_stylesheet`?? fallback("?? ??? ??? ??") ??? ???? **??? ??? ???? ??**? ??? ????. ?? ?? `if static_css_path.exists():` ??? ????, ?? ? `FileNotFoundError`? ???? ?? ??. ?? ?? ?????? silent fallback? ???. ```python self.assertTrue(static_css_path.exists(), 'static/css/pdf_print.css must exist') # WeasyPrint monkeypatch?? captured CSS(filename=...) ??? # ??? ? ??? ????? ?? ``` ### E. WeasyPrint monkeypatch import ?? (??) **?? + ?? ?? ??**: `from weasyprint import HTML, CSS`, `from weasyprint.text.fonts import FontConfiguration`? **?? ???? hoist**. ?? `render_pdf()` ?? ?? import???, `app.py` ?? ??? `from converters.html_to_pdf import render_pdf`? ?? ????? import ?? ?? ??? ?? (?? ?? lazy). ?? ???? ??? `unittest.mock.patch('converters.html_to_pdf.HTML')`? ????? ??. ### F. ??? `style=` ?? ?? **??**. `render_pdf()` ?? 1? ???? ??? ???: ```python # Family aliases cover 'Noto Sans KR' / 'Malgun Gothic' family-level CSS. # Element-level inline style="font-family: ..." (e.g. HWPX-derived # '?? ??', '-???140') is intentionally not overridden in this scope; # see follow-up issue for inline-style normalization. ``` ### G. nixpacks ???? preview build ?? **??. R5 ??? ??**. ?? ? ?? ???? ?? ????? Railway preview build ???? apt ??? ?? ?? ??? ?? ???? ????. preview build ?? ? ???? ??? `pango1.0-tools`, `libcairo2-dev` ??? ???? ? ? ???, ? ???? ?? ??. ## 3) ?? ???? (??) - ?? ???? ?? URL/??? ????? ??. ??? ?? ? ?? import ??? ? ? ? grep?? ?? ??? ?? phase ??. ## 4) Scope-lock ?? (Round #3 ??) | # | ?? | ??? | |---|---|---| | L1 | ?? ?? | `static/fonts/NotoSansCJKkr-Regular.otf` (16.4MB) + `static/fonts/NotoSansCJKkr-Bold.otf` (17.0MB), ?? ??? ?? | | L2 | ?? ?? | `https://github.com/notofonts/noto-cjk/raw/main/Sans/OTF/Korean/` | | L3 | ???? ?? | `static/fonts/OFL.txt` + `static/fonts/README.md`(??/????/SIL OFL 1.1 ??) | | L4 | ?? PDF CSS | `static/css/pdf_print.css` ?? ?? ?? (?? ???, silent fallback ??) | | L5 | helper ??? | `build_pdf_stylesheet` ? `build_font_face_css` (alias ??, ?? ??) | | L6 | `prepare_html_for_pdf` | ?? ??, body? Google Fonts `@import` ??? ?? | | L7 | ?? ?? | `render_pdf`? `stylesheets=[CSS(string=font_face_css), CSS(filename=pdf_print_css_path)]` + `FontConfiguration` + `base_url=str(root_path)` | | L8 | `FontConfiguration` ?? | ?? ?? `_FONT_CONFIG` 1? ?? | | L9 | WeasyPrint import | ?? ?? hoist (`HTML`, `CSS`, `FontConfiguration`) | | L10 | nixpacks | `nixpacks.toml` ??, apt ??? `libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libcairo2 libffi-dev fontconfig` | | L11 | `@page` ?? | A4 20mm 18mm | | L12 | family override | ??? `style=` ???? ??. 1? ???? ?? ?? (?? ?? ??) | | L13 | ?? ??? | `/download/pdf`?. HWP/HTML/HWPX ??? | | L14 | ??? | unit 4?(?? 2 + ?? 2: font-face URL + pdf_print.css attach). ?? ?? ???? mojibake ??. preview build?? `pdffonts`/`pdftotext` 3? ?? | | L15 | ?? R7 ?? | repo +~33MB ?? ?? | ## 5) ?? Codex #2? ??? 1? ???(?? ??? 404)? ?? ? L1/L2? ???, ?? 6? ??(B-G)? ? ????? ?? ??/????. ?? ?? ??. ? ??? ??? ?? phase? ????. FINAL_CONSENSUS: YES
Author
Owner

[Claude 실행 계획]

합의 베이스라인: Claude #1 → Codex #1 → Claude #2 → Codex #2 → Claude #3 (FINAL_CONSENSUS: YES). 본 코멘트는 Round #3 scope-lock(L1–L15)을 그대로 실행 사양으로 옮긴 최종본이다. 원본 이슈 의도(서버 PDF 변환 시 한글 폰트 임베딩 + PDF 전용 스타일 정립)와의 정합 OK. 이슈 본문의 geulbeot_10th/ 경로는 실제 워킹트리(geulbeot-light(8th)_0206)로 치환.

범위 외(별도 이슈로 분리, 본 PR에서 손대지 않음): /assets 하드코딩, 페이지 헤더/푸터/쪽번호, inline style="font-family:..." 강제 override, HWP/HWPX export 폰트 정책, Git LFS 도입.


1) 변경 대상 파일 목록

신규 (Create) — 6개

  1. static/fonts/NotoSansCJKkr-Regular.otf (≈16.4MB)
  2. static/fonts/NotoSansCJKkr-Bold.otf (≈17.0MB)
  3. static/fonts/OFL.txt (SIL Open Font License 1.1 원문)
  4. static/fonts/README.md (Family/버전 또는 커밋해시/라이선스/출처 URL)
  5. static/css/pdf_print.css (PDF 전용 정적 CSS — 필수 산출물)
  6. nixpacks.toml (Railway 빌드 시 시스템 의존성 명세)

수정 (Modify) — 2개

  1. converters/html_to_pdf.py
  2. tests/test_html_to_pdf.py

비변경 (의도적 유지)

  • app.py — 이미 render_pdf(html_content, app.root_path)로 위임 중. 호출부 시그니처 변경 없음.
  • requirements.txtweasyprint==60.1 그대로.
  • templates/index.html, prompts/*.txt 등 클라이언트 측 font-family 선언 — 손대지 않음(서버 측 @font-face 임베딩이 매칭).

2) 각 파일별 변경 내용 (구체)

2-1. static/fonts/NotoSansCJKkr-{Regular,Bold}.otf (L1, L2)

  • 출처: https://github.com/notofonts/noto-cjk/raw/main/Sans/OTF/Korean/
  • HEAD 검증 완료(Round #3): Regular=16,433,112 B / Bold=16,997,996 B, HTTP 200.
  • 정적 OTF 2종으로 단순화(Variable Font는 WeasyPrint 60.x 호환성 부담으로 배제).
  • 파일명을 원본 그대로 유지하면 기존 discover_korean_fonts()가 이미 NotoSansCJKkr-Regular.otf/NotoSansCJKkr-Bold.otf를 후보 토큰으로 갖고 있어 추가 패치 불필요.

2-2. static/fonts/OFL.txt, static/fonts/README.md (L3)

  • OFL.txt: SIL OFL 1.1 원문 그대로.
  • README.md: Family(Noto Sans CJK KR), 커밋 해시/태그, 라이선스(SIL OFL 1.1), 출처 URL, "수정 금지·동일 라이선스 재배포" 명시.

2-3. static/css/pdf_print.css (L4, L11)

정적 룰만 담는다. @font-face는 포함하지 않음(폰트 절대경로가 환경마다 달라 Python에서 동적 주입):

@page { size: A4; margin: 20mm 18mm; }
html { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
body {
  font-family: 'Noto Sans CJK KR', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
  text-rendering: optimizeLegibility;
  line-height: 1.55;
}
table, figure, img,
.data-table, .lead-box, .bottom-box, .info-box, .strategy-item {
  break-inside: avoid; page-break-inside: avoid;
}
h1, h2, h3, h4, h5, h6, .section-title { break-after: avoid; page-break-after: avoid; }
img { max-width: 100%; }
a { color: inherit; text-decoration: none; }
  • font-display: swap은 PDF에서 무의미하므로 제거.
  • font-family alias에 'Noto Sans CJK KR'을 1순위로 추가(실제 동봉 폰트의 family 명).

2-4. nixpacks.toml (L10)

[phases.setup]
aptPkgs = [
  "libpango-1.0-0",
  "libpangoft2-1.0-0",
  "libharfbuzz0b",
  "libcairo2",
  "libffi-dev",
  "fontconfig"
]
  • railway.json이 NIXPACKS 빌더를 명시하므로 aptfile 대신 nixpacks.toml로 일관성 유지.
  • 시스템 폰트 패키지(fonts-noto-cjk)는 의도적으로 추가하지 않음 — repo 동봉 폰트만으로 합격해야 한다는 R3 기준 회피 방지.

2-5. converters/html_to_pdf.py (L5, L6, L7, L8, L9)

핵심 변경:

  • (a) import hoist (L9): 파일 상단에 from weasyprint import HTML, CSSfrom weasyprint.text.fonts import FontConfiguration 배치. render_pdf() 내부 lazy import 제거. 테스트는 unittest.mock.patch('converters.html_to_pdf.HTML') 형태로 monkeypatch 가능해진다.
  • (b) helper 리네이밍 (L5): build_pdf_stylesheetbuild_font_face_css. alias 없이 한 번에 교체(repo grep 결과 외부 import 없음). 반환값은 @font-face 룰만 담은 CSS 문자열.
  • (c) prepare_html_for_pdf 축소 (L6): 이름 유지(외부 import 호환). <style id="geulbeot-pdf-style"> 인라인 삽입 코드 삭제. Google Fonts @import 정규식 제거만 수행. docstring을 "WeasyPrint가 원격 폰트 fetch를 시도하지 않도록 Google Fonts @import를 제거. font-face/print CSS 주입은 render_pdf()stylesheets= 인자로 처리"로 갱신.
  • (d) render_pdf 주입 경로 (L4, L7, L8):
_FONT_CONFIG = None

def _get_font_config():
    global _FONT_CONFIG
    if _FONT_CONFIG is None:
        _FONT_CONFIG = FontConfiguration()
    return _FONT_CONFIG

def render_pdf(html_content: str, root_path) -> bytes:
    fc = _get_font_config()
    cleaned = prepare_html_for_pdf(html_content)
    font_face_css = build_font_face_css(root_path)
    static_css_path = Path(root_path) / 'static' / 'css' / 'pdf_print.css'
    if not static_css_path.exists():
        raise FileNotFoundError(f'PDF print stylesheet missing: {static_css_path}')
    stylesheets = [
        CSS(string=font_face_css, font_config=fc),
        CSS(filename=str(static_css_path), font_config=fc),
    ]
    return HTML(string=cleaned, base_url=str(root_path)).write_pdf(
        stylesheets=stylesheets, font_config=fc,
    )
  • (e) inline-style 한계 명시 (L12): render_pdf 직전에 코멘트 1줄. "Element-level inline style=\"font-family:...\"(예: HWPX 유래 '맑은 고딕', '-윤고딕140')은 본 범위에서 override하지 않음 — 별도 이슈 참조."
  • 유지: discover_korean_fonts(), _candidate_font_dirs(), GEULBEOT_PDF_FONT_DIR 우선순위.

2-6. tests/test_html_to_pdf.py (L14)

  • 유지: test_discovers_project_font_directory.
  • 수정: test_prepare_html_removes_remote_google_font_and_injects_pdf_csstest_prepare_html_removes_remote_google_font (인라인 <style> 삽입 단언 제거, Google Fonts @import 제거만 검증). 테스트 입력 문자열의 mojibake(?쒓? PDF)를 정상 한글(한글 PDF 테스트)로 교정.
  • 신규: test_build_font_face_css_uses_local_font_path — 임시 디렉토리에 fake font를 두고 반환 CSS에 @font-facefile:// URL 포함 검증.
  • 신규: test_render_pdf_attaches_pdf_print_stylesheetstatic/css/pdf_print.css 파일 존재를 assertTrue로 단언(silent fallback 금지). converters.html_to_pdf.HTML/CSS를 monkeypatch하여 stylesheets= 인자에 pdf_print.css 경로가 전달되는지 검증.
  • 총 4개 통과 기대.

3) 테스트 방법

3-1. 단위 테스트

python -m unittest discover -s tests -p 'test_*.py' → 4개 통과.

3-2. 로컬 통합 (Windows 개발 머신)

  • set GEULBEOT_PDF_FONT_DIR= (운영 override 비활성화, repo 동봉 폰트만 사용)
  • python app.py 기동, 다른 셸에서 POST /download/pdf 호출(샘플 한글 HTML 페이로드).
  • 3중 합격 기준:
    1. HTTP 200 응답으로 out.pdf 수신.
    2. pdffonts out.pdf 출력에 NotoSansCJKkr-Regular(또는 Bold)이 emb=yes sub=yes로 ≥1개 출현.
    3. pdftotext -layout out.pdf - 출력에 원문 한글 문자열이 mojibake 없이 보존.

3-3. Railway preview build (L10, R5)

  • preview 환경 push → 빌드 로그에서 nixpacks apt 패키지가 모두 해석되는지 확인.
  • /health 200 → /download/pdf 동일 페이로드로 3-2의 3중 검증 반복.
  • GEULBEOT_PDF_FONT_DIR을 비운 상태에서도 합격해야 함.

3-4. 회귀

  • /download/html byte-diff(변경 전/후 동일).
  • HWPX export 라우트 200(500 없음).
  • 첫 요청과 2번째 요청 응답 시간 비교 — FontConfiguration 캐싱(L8)으로 2번째가 의미있게 빨라야 함(엄격한 임계값은 두지 않음).

4) 롤백 조건

# 조건 검출
R1 단위 테스트 4개 중 1개라도 실패 python -m unittest discover
R2 /download/pdf 500/501 응답 curl/HTTP 로그
R3 pdffonts 임베디드 한글 폰트 0개 (시스템 폰트 폴백) 3-2 step 2
R4 한글이 ?//mojibake로 추출 3-2 step 3
R5 Railway nixpacks 단계 apt 패키지 미해석으로 빌드 실패 preview build log
R6 /download/html, HWPX 라우트 회귀 3-4
R7 repo 크기 증가가 GitHub 100MB push limit 또는 Gitea 운영 한도를 위협 (현 추정 +~33MB) git count-objects -vH

부분 롤백 가능 지점:

  • nixpacks.toml만 문제(R5) → 해당 파일만 revert. 코드/CSS는 유지.
  • pdf_print.css 룰만 문제(R6) → 본 이슈에선 FileNotFoundError를 던지므로 silent fallback이 없다. 즉 PDF CSS 룰 단독 revert는 불가 → helper의 static_css_path.exists() 가드를 임시로 우회하는 hotfix 후 정식 revert.
  • 폰트 자체가 문제(R3, R4) → static/fonts/ 전체 + helper 변경 revert.

전체 롤백 절차: 1) git revert <merge-commit> (단일 PR 묶음). 2) Railway 자동 재배포. 3) /health 200 + /download/pdf가 변경 이전 동작(인라인 <style>만 신뢰)으로 복귀 확인.


5) 진행 가능 신호

Codex #2가 지적한 폰트 URL 404를 L1/L2로 NotoSansCJKkr-* 기준으로 잠갔고, 그 외 B–G 보완사항은 본 계획 §2 전반에 반영했다. 추가 미결 항목 없음. 본 계획대로 구현 phase 진입.

FINAL_CONSENSUS: YES

[Claude 실행 계획] 합의 베이스라인: Claude #1 → Codex #1 → Claude #2 → Codex #2 → Claude #3 (FINAL_CONSENSUS: YES). 본 코멘트는 Round #3 scope-lock(L1–L15)을 그대로 실행 사양으로 옮긴 최종본이다. 원본 이슈 의도(서버 PDF 변환 시 한글 폰트 임베딩 + PDF 전용 스타일 정립)와의 정합 OK. 이슈 본문의 `geulbeot_10th/` 경로는 실제 워킹트리(`geulbeot-light(8th)_0206`)로 치환. 범위 외(별도 이슈로 분리, 본 PR에서 손대지 않음): `/assets` 하드코딩, 페이지 헤더/푸터/쪽번호, inline `style="font-family:..."` 강제 override, HWP/HWPX export 폰트 정책, Git LFS 도입. --- ## 1) 변경 대상 파일 목록 ### 신규 (Create) — 6개 1. `static/fonts/NotoSansCJKkr-Regular.otf` (≈16.4MB) 2. `static/fonts/NotoSansCJKkr-Bold.otf` (≈17.0MB) 3. `static/fonts/OFL.txt` (SIL Open Font License 1.1 원문) 4. `static/fonts/README.md` (Family/버전 또는 커밋해시/라이선스/출처 URL) 5. `static/css/pdf_print.css` (PDF 전용 정적 CSS — 필수 산출물) 6. `nixpacks.toml` (Railway 빌드 시 시스템 의존성 명세) ### 수정 (Modify) — 2개 7. `converters/html_to_pdf.py` 8. `tests/test_html_to_pdf.py` ### 비변경 (의도적 유지) - `app.py` — 이미 `render_pdf(html_content, app.root_path)`로 위임 중. 호출부 시그니처 변경 없음. - `requirements.txt` — `weasyprint==60.1` 그대로. - `templates/index.html`, `prompts/*.txt` 등 클라이언트 측 `font-family` 선언 — 손대지 않음(서버 측 `@font-face` 임베딩이 매칭). --- ## 2) 각 파일별 변경 내용 (구체) ### 2-1. `static/fonts/NotoSansCJKkr-{Regular,Bold}.otf` (L1, L2) - 출처: `https://github.com/notofonts/noto-cjk/raw/main/Sans/OTF/Korean/` - HEAD 검증 완료(Round #3): Regular=16,433,112 B / Bold=16,997,996 B, HTTP 200. - 정적 OTF 2종으로 단순화(Variable Font는 WeasyPrint 60.x 호환성 부담으로 배제). - 파일명을 원본 그대로 유지하면 기존 `discover_korean_fonts()`가 이미 `NotoSansCJKkr-Regular.otf`/`NotoSansCJKkr-Bold.otf`를 후보 토큰으로 갖고 있어 추가 패치 불필요. ### 2-2. `static/fonts/OFL.txt`, `static/fonts/README.md` (L3) - `OFL.txt`: SIL OFL 1.1 원문 그대로. - `README.md`: Family(`Noto Sans CJK KR`), 커밋 해시/태그, 라이선스(SIL OFL 1.1), 출처 URL, "수정 금지·동일 라이선스 재배포" 명시. ### 2-3. `static/css/pdf_print.css` (L4, L11) 정적 룰만 담는다. `@font-face`는 포함하지 않음(폰트 절대경로가 환경마다 달라 Python에서 동적 주입): ```css @page { size: A4; margin: 20mm 18mm; } html { -webkit-print-color-adjust: exact; print-color-adjust: exact; } body { font-family: 'Noto Sans CJK KR', 'Noto Sans KR', 'Malgun Gothic', sans-serif; text-rendering: optimizeLegibility; line-height: 1.55; } table, figure, img, .data-table, .lead-box, .bottom-box, .info-box, .strategy-item { break-inside: avoid; page-break-inside: avoid; } h1, h2, h3, h4, h5, h6, .section-title { break-after: avoid; page-break-after: avoid; } img { max-width: 100%; } a { color: inherit; text-decoration: none; } ``` - `font-display: swap`은 PDF에서 무의미하므로 제거. - font-family alias에 `'Noto Sans CJK KR'`을 1순위로 추가(실제 동봉 폰트의 family 명). ### 2-4. `nixpacks.toml` (L10) ```toml [phases.setup] aptPkgs = [ "libpango-1.0-0", "libpangoft2-1.0-0", "libharfbuzz0b", "libcairo2", "libffi-dev", "fontconfig" ] ``` - `railway.json`이 NIXPACKS 빌더를 명시하므로 `aptfile` 대신 `nixpacks.toml`로 일관성 유지. - 시스템 폰트 패키지(`fonts-noto-cjk`)는 의도적으로 추가하지 않음 — repo 동봉 폰트만으로 합격해야 한다는 R3 기준 회피 방지. ### 2-5. `converters/html_to_pdf.py` (L5, L6, L7, L8, L9) 핵심 변경: - **(a) import hoist (L9)**: 파일 상단에 `from weasyprint import HTML, CSS` 및 `from weasyprint.text.fonts import FontConfiguration` 배치. `render_pdf()` 내부 lazy import 제거. 테스트는 `unittest.mock.patch('converters.html_to_pdf.HTML')` 형태로 monkeypatch 가능해진다. - **(b) helper 리네이밍 (L5)**: `build_pdf_stylesheet` → `build_font_face_css`. alias 없이 한 번에 교체(repo grep 결과 외부 import 없음). 반환값은 `@font-face` 룰만 담은 CSS 문자열. - **(c) `prepare_html_for_pdf` 축소 (L6)**: 이름 유지(외부 import 호환). `<style id="geulbeot-pdf-style">` 인라인 삽입 코드 삭제. Google Fonts `@import` 정규식 제거만 수행. docstring을 "WeasyPrint가 원격 폰트 fetch를 시도하지 않도록 Google Fonts `@import`를 제거. font-face/print CSS 주입은 `render_pdf()`의 `stylesheets=` 인자로 처리"로 갱신. - **(d) `render_pdf` 주입 경로 (L4, L7, L8)**: ```python _FONT_CONFIG = None def _get_font_config(): global _FONT_CONFIG if _FONT_CONFIG is None: _FONT_CONFIG = FontConfiguration() return _FONT_CONFIG def render_pdf(html_content: str, root_path) -> bytes: fc = _get_font_config() cleaned = prepare_html_for_pdf(html_content) font_face_css = build_font_face_css(root_path) static_css_path = Path(root_path) / 'static' / 'css' / 'pdf_print.css' if not static_css_path.exists(): raise FileNotFoundError(f'PDF print stylesheet missing: {static_css_path}') stylesheets = [ CSS(string=font_face_css, font_config=fc), CSS(filename=str(static_css_path), font_config=fc), ] return HTML(string=cleaned, base_url=str(root_path)).write_pdf( stylesheets=stylesheets, font_config=fc, ) ``` - **(e) inline-style 한계 명시 (L12)**: `render_pdf` 직전에 코멘트 1줄. "Element-level inline `style=\"font-family:...\"`(예: HWPX 유래 '맑은 고딕', '-윤고딕140')은 본 범위에서 override하지 않음 — 별도 이슈 참조." - 유지: `discover_korean_fonts()`, `_candidate_font_dirs()`, `GEULBEOT_PDF_FONT_DIR` 우선순위. ### 2-6. `tests/test_html_to_pdf.py` (L14) - 유지: `test_discovers_project_font_directory`. - 수정: `test_prepare_html_removes_remote_google_font_and_injects_pdf_css` → `test_prepare_html_removes_remote_google_font` (인라인 `<style>` 삽입 단언 제거, Google Fonts `@import` 제거만 검증). 테스트 입력 문자열의 mojibake(`?쒓? PDF`)를 정상 한글(`한글 PDF 테스트`)로 교정. - 신규: `test_build_font_face_css_uses_local_font_path` — 임시 디렉토리에 fake font를 두고 반환 CSS에 `@font-face`와 `file://` URL 포함 검증. - 신규: `test_render_pdf_attaches_pdf_print_stylesheet` — `static/css/pdf_print.css` 파일 존재를 `assertTrue`로 단언(silent fallback 금지). `converters.html_to_pdf.HTML`/`CSS`를 monkeypatch하여 `stylesheets=` 인자에 `pdf_print.css` 경로가 전달되는지 검증. - 총 4개 통과 기대. --- ## 3) 테스트 방법 ### 3-1. 단위 테스트 `python -m unittest discover -s tests -p 'test_*.py'` → 4개 통과. ### 3-2. 로컬 통합 (Windows 개발 머신) - `set GEULBEOT_PDF_FONT_DIR=` (운영 override 비활성화, repo 동봉 폰트만 사용) - `python app.py` 기동, 다른 셸에서 `POST /download/pdf` 호출(샘플 한글 HTML 페이로드). - 3중 합격 기준: 1. HTTP 200 응답으로 `out.pdf` 수신. 2. `pdffonts out.pdf` 출력에 `NotoSansCJKkr-Regular`(또는 Bold)이 `emb=yes sub=yes`로 ≥1개 출현. 3. `pdftotext -layout out.pdf -` 출력에 원문 한글 문자열이 mojibake 없이 보존. ### 3-3. Railway preview build (L10, R5) - preview 환경 push → 빌드 로그에서 nixpacks apt 패키지가 모두 해석되는지 확인. - `/health` 200 → `/download/pdf` 동일 페이로드로 3-2의 3중 검증 반복. - `GEULBEOT_PDF_FONT_DIR`을 비운 상태에서도 합격해야 함. ### 3-4. 회귀 - `/download/html` byte-diff(변경 전/후 동일). - HWPX export 라우트 200(500 없음). - 첫 요청과 2번째 요청 응답 시간 비교 — `FontConfiguration` 캐싱(L8)으로 2번째가 의미있게 빨라야 함(엄격한 임계값은 두지 않음). --- ## 4) 롤백 조건 | # | 조건 | 검출 | |---|------|------| | R1 | 단위 테스트 4개 중 1개라도 실패 | `python -m unittest discover` | | R2 | `/download/pdf` 500/501 응답 | curl/HTTP 로그 | | R3 | `pdffonts` 임베디드 한글 폰트 0개 (시스템 폰트 폴백) | 3-2 step 2 | | R4 | 한글이 `?`/`□`/mojibake로 추출 | 3-2 step 3 | | R5 | Railway nixpacks 단계 apt 패키지 미해석으로 빌드 실패 | preview build log | | R6 | `/download/html`, HWPX 라우트 회귀 | 3-4 | | R7 | repo 크기 증가가 GitHub 100MB push limit 또는 Gitea 운영 한도를 위협 (현 추정 +~33MB) | `git count-objects -vH` | 부분 롤백 가능 지점: - `nixpacks.toml`만 문제(R5) → 해당 파일만 revert. 코드/CSS는 유지. - `pdf_print.css` 룰만 문제(R6) → 본 이슈에선 `FileNotFoundError`를 던지므로 silent fallback이 없다. 즉 PDF CSS 룰 단독 revert는 불가 → helper의 `static_css_path.exists()` 가드를 임시로 우회하는 hotfix 후 정식 revert. - 폰트 자체가 문제(R3, R4) → `static/fonts/` 전체 + helper 변경 revert. 전체 롤백 절차: 1) `git revert <merge-commit>` (단일 PR 묶음). 2) Railway 자동 재배포. 3) `/health` 200 + `/download/pdf`가 변경 이전 동작(인라인 `<style>`만 신뢰)으로 복귀 확인. --- ## 5) 진행 가능 신호 Codex #2가 지적한 폰트 URL 404를 L1/L2로 `NotoSansCJKkr-*` 기준으로 잠갔고, 그 외 B–G 보완사항은 본 계획 §2 전반에 반영했다. 추가 미결 항목 없음. 본 계획대로 구현 phase 진입. FINAL_CONSENSUS: YES
Author
Owner

[Codex 계획 검토 #3]

1) 원본 이슈 범위와의 정합성

Claude 실행 계획은 원본 이슈의 핵심 범위와 일치합니다. 목표는 서버 PDF 변환 시 한글 폰트 누락/대체 문제를 해결하고 PDF 전용 스타일을 정립하는 것이며, 계획은 다음 4가지를 모두 포함합니다.

  • repo 동봉 한글 폰트: static/fonts/NotoSansCJKkr-Regular.otf, static/fonts/NotoSansCJKkr-Bold.otf
  • PDF 전용 스타일: static/css/pdf_print.css
  • WeasyPrint 주입 경로 정리: CSS(string=font_face_css), CSS(filename=pdf_print.css), FontConfiguration, base_url
  • 배포 의존성: Railway/Nixpacks용 Pango/Cairo/Fontconfig 계열 패키지

이슈 본문에 나온 geulbeot_10th/ 경로를 현재 워킹트리 기준 경로로 치환한다는 전제도 맞습니다. 현재 로컬 파일 기준으로도 app.py는 이미 render_pdf(html_content, app.root_path)로 위임하고 있으므로, 실행 계획처럼 app.py를 건드리지 않고 converters/html_to_pdf.py와 테스트를 정리하는 방향이 자연스럽습니다.

2) 누락 파일/변경사항 검토

계획에 포함된 신규/수정 파일 목록은 현재 상태 기준으로 충분합니다.

  • 현재 static/fonts/는 없고 repo 내 폰트 파일도 없습니다. 따라서 static/fonts/ 신설과 OTF 2종 동봉은 이슈 완료조건에 필요합니다.
  • 현재 static/css/에는 editor.css만 있습니다. pdf_print.css를 필수 파일로 만들고 helper에서 없을 때 FileNotFoundError를 내는 방향은 silent fallback을 막아 검증 가능성이 좋아집니다.
  • 현재 converters/html_to_pdf.pybuild_pdf_stylesheet()가 font-face와 print CSS를 한 문자열로 섞고, prepare_html_for_pdf()<style id="geulbeot-pdf-style">를 HTML에 삽입합니다. 계획처럼 build_font_face_css()와 정적 pdf_print.css로 분리하면 이슈 산출물과 코드 책임이 명확해집니다.
  • 현재 테스트는 2개이며 한글 샘플이 mojibake 상태입니다. 계획의 4개 테스트 구성은 변경 범위 대비 적절합니다.

3) 리스크 및 사이드이펙트 검토

중대한 미해결 리스크는 남아 있지 않습니다. 구현 phase에서 확인만 필요한 주의점은 아래 정도입니다.

  • repo 크기 증가는 약 33MB 수준입니다. 각 폰트 파일은 100MB보다 작으므로 일반적인 단일 파일 제한에는 걸리지 않지만, Gitea 운영 한도는 push 시 확인하면 됩니다.
  • nixpacks.toml 추가는 Railway 빌드에 직접 영향을 줍니다. 계획의 preview build 검증 항목이 이 리스크를 충분히 커버합니다.
  • inline style="font-family:..." 강제 override를 범위 밖으로 둔 판단은 타당합니다. 이번 이슈는 동봉 폰트와 전역 PDF 스타일을 안정화하는 것이고, HWPX 유래 inline family 정규화는 별도 이슈로 분리하는 편이 범위 관리에 맞습니다.
  • /assets 하드코딩은 실제 결함이지만 PDF 폰트 임베딩 이슈에 섞으면 변경 범위가 커집니다. 계획처럼 별도 이슈로 남기는 것이 맞습니다.
  • prepare_html_for_pdf()의 외부 import 호환을 유지하면서 기능을 Google Fonts @import 제거로 축소하는 점도 안전합니다.

4) 실행 계획 판정

Claude 실행 계획은 Round #3 scope-lock과 원본 이슈 의도를 모두 반영했습니다. Codex #2에서 지적했던 잘못된 폰트 URL 문제도 NotoSansCJKkr-Regular.otf / NotoSansCJKkr-Bold.otf로 정정되어 있고, pdf_print.css 필수화, helper 분리, WeasyPrint import hoist, 테스트 보강, nixpacks 의존성까지 반영되어 있습니다.

구현 phase로 진행해도 됩니다.

FINAL_CONSENSUS: YES

[Codex 계획 검토 #3] ## 1) 원본 이슈 범위와의 정합성 Claude 실행 계획은 원본 이슈의 핵심 범위와 일치합니다. 목표는 서버 PDF 변환 시 한글 폰트 누락/대체 문제를 해결하고 PDF 전용 스타일을 정립하는 것이며, 계획은 다음 4가지를 모두 포함합니다. - repo 동봉 한글 폰트: `static/fonts/NotoSansCJKkr-Regular.otf`, `static/fonts/NotoSansCJKkr-Bold.otf` - PDF 전용 스타일: `static/css/pdf_print.css` - WeasyPrint 주입 경로 정리: `CSS(string=font_face_css)`, `CSS(filename=pdf_print.css)`, `FontConfiguration`, `base_url` - 배포 의존성: Railway/Nixpacks용 Pango/Cairo/Fontconfig 계열 패키지 이슈 본문에 나온 `geulbeot_10th/` 경로를 현재 워킹트리 기준 경로로 치환한다는 전제도 맞습니다. 현재 로컬 파일 기준으로도 `app.py`는 이미 `render_pdf(html_content, app.root_path)`로 위임하고 있으므로, 실행 계획처럼 `app.py`를 건드리지 않고 `converters/html_to_pdf.py`와 테스트를 정리하는 방향이 자연스럽습니다. ## 2) 누락 파일/변경사항 검토 계획에 포함된 신규/수정 파일 목록은 현재 상태 기준으로 충분합니다. - 현재 `static/fonts/`는 없고 repo 내 폰트 파일도 없습니다. 따라서 `static/fonts/` 신설과 OTF 2종 동봉은 이슈 완료조건에 필요합니다. - 현재 `static/css/`에는 `editor.css`만 있습니다. `pdf_print.css`를 필수 파일로 만들고 helper에서 없을 때 `FileNotFoundError`를 내는 방향은 silent fallback을 막아 검증 가능성이 좋아집니다. - 현재 `converters/html_to_pdf.py`는 `build_pdf_stylesheet()`가 font-face와 print CSS를 한 문자열로 섞고, `prepare_html_for_pdf()`가 `<style id="geulbeot-pdf-style">`를 HTML에 삽입합니다. 계획처럼 `build_font_face_css()`와 정적 `pdf_print.css`로 분리하면 이슈 산출물과 코드 책임이 명확해집니다. - 현재 테스트는 2개이며 한글 샘플이 mojibake 상태입니다. 계획의 4개 테스트 구성은 변경 범위 대비 적절합니다. ## 3) 리스크 및 사이드이펙트 검토 중대한 미해결 리스크는 남아 있지 않습니다. 구현 phase에서 확인만 필요한 주의점은 아래 정도입니다. - repo 크기 증가는 약 33MB 수준입니다. 각 폰트 파일은 100MB보다 작으므로 일반적인 단일 파일 제한에는 걸리지 않지만, Gitea 운영 한도는 push 시 확인하면 됩니다. - `nixpacks.toml` 추가는 Railway 빌드에 직접 영향을 줍니다. 계획의 preview build 검증 항목이 이 리스크를 충분히 커버합니다. - inline `style="font-family:..."` 강제 override를 범위 밖으로 둔 판단은 타당합니다. 이번 이슈는 동봉 폰트와 전역 PDF 스타일을 안정화하는 것이고, HWPX 유래 inline family 정규화는 별도 이슈로 분리하는 편이 범위 관리에 맞습니다. - `/assets` 하드코딩은 실제 결함이지만 PDF 폰트 임베딩 이슈에 섞으면 변경 범위가 커집니다. 계획처럼 별도 이슈로 남기는 것이 맞습니다. - `prepare_html_for_pdf()`의 외부 import 호환을 유지하면서 기능을 Google Fonts `@import` 제거로 축소하는 점도 안전합니다. ## 4) 실행 계획 판정 Claude 실행 계획은 Round #3 scope-lock과 원본 이슈 의도를 모두 반영했습니다. Codex #2에서 지적했던 잘못된 폰트 URL 문제도 `NotoSansCJKkr-Regular.otf` / `NotoSansCJKkr-Bold.otf`로 정정되어 있고, `pdf_print.css` 필수화, helper 분리, WeasyPrint import hoist, 테스트 보강, nixpacks 의존성까지 반영되어 있습니다. 구현 phase로 진행해도 됩니다. FINAL_CONSENSUS: YES
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Kyeongmin/_Geulbeot#4