웹에서 리포트나 스크립트를 PDF로 추출해야 할 때가 있다.
단순히 화면을 캡처해서 PDF로 변환하면 되지 않을까? 싶지만,
실제로는 페이지가 넘어가는 지점에서 콘텐츠가 잘리거나, 여백이 뒤틀리는 문제를 마주하게 된다.
이때 html-to-image와 jsPDF 라이브러리를 사용해서
A4 용지 기준으로 페이지 단위가 정확히 나뉘는 PDF를 만드는 방법을 정리한다.
DOM 요소를 이미지(PNG, JPEG, SVG 등)로 변환해주는 라이브러리다.
내부적으로 DOM을 순회하며 인라인 스타일, 폰트, 이미지 등을 직렬화하여
<canvas>를 거치지 않고도 고품질 캡처가 가능하다.
import * as htmlToImage from 'html-to-image';
const dataUrl = await htmlToImage.toJpeg(element, { pixelRatio: 3 });
/* pixelRatio를 높이면 해상도가 올라간다.
리포트 PDF처럼 텍스트가 선명해야 하는 경우 3 정도를 권장한다. */
JavaScript로 PDF 문서를 생성하는 라이브러리다.
페이지 추가, 이미지 삽입, 텍스트 배치 등을 코드로 제어할 수 있다.
import jsPDF from 'jspdf';
const doc = new jsPDF('p', 'mm', 'a4', true);
// 'p': portrait, 'mm': 밀리미터 단위, 'a4': 용지 크기
이 두 라이브러리를 조합하면 흐름은 간단하다:
DOM → 이미지 캡처 → PDF에 이미지 삽입 → 페이지 분할
페이지 한 장에 담길 데이터의 개수 만큼 chunk로 자른 후, 페이지를 map으로 돌려서 여러 장을 렌더시키는 방식이다.
이때 map으로 돌릴 때 페이지 컨테이너의 높이를 상수로 고정시켜야 하는데, 911px로 고정했다.
// A4 용지 사이즈의 height 값
const A4_HEIGHT_SIZE = 911;
<Stack sx={{ width: '100%', height: `${A4_HEIGHT_SIZE}px`, padding: '10px' }}>
{/* A4 내용물을 렌더링 */}
</Stack>
A4 용지의 실제 사이즈는 210mm × 297mm이다.
PDF 변환 시 jsPDF는 mm 단위로 동작하지만, 화면에 렌더링되는 DOM은 px 단위로 동작한다. 이 둘을 연결하려면 한 페이지가 몇 px인지를 고정해야 한다.
컨테이너 width가 약 643px일 때 A4 비율을 적용하면,
643 × 1.4142 ≈ 909 ~ 911px
즉 911px은 A4 비율(1:√2)을 px로 환산한 근사값이기 때문에, 이를 A4 사이즈로 잡았다.
페이지 컨테이너의 고정 높이가 정해졌다면, 그 안의 영역별 높이를 정확히 분배해야 한다.
디자인 시안에 맞춰서 영역별 높이를 분배해보자.
그렇지 않으면 콘텐츠가 푸터를 침범하거나, 다음 페이지로 넘쳐흐른다.
┌─────────────────────────┐
│ Header (83px) │ ← 제목, 콘텐츠 정보
├─────────────────────────┤
│ │
│ Content (804px) │ ← 911 - 83 - 24 = 804px
│ padding: 10px │
│ │
├─────────────────────────┤
│ Footer (24px) │ ← 페이지 번호
└─────────────────────────┘
총 911px
이것을 /const/상수.ts 로 생성하여 따로 관리했다.
export const A4_HEIGHT_SIZE = 911;
export const HEADER_HEIGHT = 83;
export const FOOTER_HEIGHT = 24;
export const PADDING_VALUE = 10;
export const CONTENT_HEIGHT = A4_HEIGHT_SIZE - (HEADER_HEIGHT + FOOTER_HEIGHT);
// 911 - 83 - 24 = 804px
일반적인 웹 페이지는 스크롤이 있어서 콘텐츠가 얼마나 길든 상관없다.
하지만 PDF는 물리적 용지다.
한 장의 경계를 넘으면 다음 장으로 넘어가야 하고, 넘어가는 지점에서 콘텐츠가 잘리면 안 된다.
따라서
1️⃣ 전체 높이(911px)에서 헤더와 푸터를 먼저 빼고
2️⃣ 남은 콘텐츠 영역(804px)에 몇 개의 아이템이 들어갈 수 있는지 계산한 뒤
3️⃣ 초과분은 다음 페이지로 넘겨서 동일한 구조(헤더-콘텐츠-푸터)로 반복 렌더링한다.
같은 PDF이지만 첫 페이지에만 존재하는 요소가 있다.
예를 들면
[1페이지] [2페이지~]
┌─────────────────────────┐ ┌─────────────────────────┐
│ Header (리포트 타이틀) │ │ │
├─────────────────────────┤ │ │
│ Title 영역 │ │ │
│ - 학원 로고 │ │ Content (856px) │
│ - 학생 정보 │ │ - 테이블 (헤더 없음) │
│ - 점수/완료일 │ │ - 최대 12개 항목 │
│ │ │ │
│ Content (810px) │ │ │
│ - 테이블 (헤더 있음) │ │ │
│ - 최대 9개 항목 │ │ │
│ - 범례(Status) │ │ - 범례(Status) │
├─────────────────────────┤ ├─────────────────────────┤
│ Footer │ │ Footer │
└─────────────────────────┘ └─────────────────────────┘
총 911px 총 911px
일반적으로 첫 페이지에는 헤더(타이틀) + 콘텐츠 + 푸터 등의 정보가 포함되고, 이후 다른 페이지에는 콘텐츠 + 푸터 등의 정보만 포함된다.
보통 헤더, 푸터 영역이 차지하는 높이만큼 콘텐츠 영역이 줄어든다.
// FirstPage — 메타 정보가 있으므로 Content 높이 810px
<Stack spacing="12px" sx={{ p: '14px 24px', height: '810px' }}>
<Title ... /> {/* 로고, 유저 정보 등 */}
<Table hasTableHeader ... /> {/* 테이블 헤더 포함 */}
</Stack>
// OtherPage — 메타 정보 없으므로 Content 높이 856px
<Stack spacing="12px" sx={{ p: '31px 24px 20px 24px', height: '856px' }}>
<PronunciationTable hasTableHeader={false} ... /> {/* 테이블 헤더 생략 */}
</Stack>
콘텐츠 높이가 다르니, 한 페이지에 담을 수 있는 데이터 수도 달라진다.
이를 다른 메서드로 빼서 처리했다.
export function paginate(data: Speak[]): Speak[][] {
const firstPageData = _.take(data, 9); // 첫 페이지: 9개
const rest = _.drop(data, 9);
const otherPages = _.chunk(rest, 12); // 이후 페이지: 12개씩
return [firstPageData, ...otherPages];
}
그리고 렌더링 시 index로 분기한다.
{_.map(pageData, (page, index) => (
<Stack key={index} sx={{ width: '100%', height: '911px', padding: '10px' }}>
{index === 0
? <PDFFirstPage ... /> // 첫 페이지
: <PDFOtherPage ... /> // 이후 페이지
}
</Stack>
))}
여기서 더 까다로운 문제가 생길 수 있다.
데이터의 텍스트가 한 줄인지 두 줄인지에 따라 한 페이지에 들어가는 아이템 수가 달라진다.
예를 들어 자막이 주요 기능이라면
[한 줄 모드] [두 줄 모드]
┌─────────────────────┐ ┌─────────────────────┐
│ Hello, world. │ ~42px │ Hello, world. │
│ Nice to meet you. │ ~42px │ 안녕하세요. │ ~86px
│ How are you? │ ~42px │ Nice to meet you. │
│ ... │ │ 만나서 반갑습니다. │ ~86px
│ (최대 15문장) │ │ ... │
└─────────────────────┘ │ (최대 8문장) │
└─────────────────────┘
행 높이가 다르니, 한 페이지에 수용할 수 있는 최대 문장 수도 다르다.
따라서 한 줄 & 두 줄 높이의 고정 사이즈를 각각 나누어 상수로 관리해야 한다.
// 한 줄 높이 (영어만 or 한국어만)
export const ONE_LINE_MIN_HEIGHT = 42;
export const ONE_LINE_MID_HEIGHT = 44;
export const ONE_LINE_MAX_HEIGHT = 46;
// 두 줄 높이 (영어 + 한국어)
export const TWO_LINE_HEIGHT = 86;
// 한 페이지 최대 문장 수
export const ONE_LINE_MAX_SENTENCE = 15; // 804 / ~46 ≈ 17 → 여유 두고 15
export const TWO_LINE_MAX_SENTENCE = 8; // 804 / 86 ≈ 9 → 여유 두고 8
이 값들을 기반으로 데이터를 chunk로 페이지 단위로 나누었다.
const trimParaNum = useMemo(
() => _.chunk(
originalSync,
isOneLine ? ONE_LINE_MAX_SENTENCE : TWO_LINE_MAX_SENTENCE
),
[originalSync, isOneLine]
);
이렇게 하면 한 줄 모드에서는 15문장씩, 두 줄 모드에서는 8문장씩
정확히 A4 한 장에 맞게 분할되어 페이지 경계에서 콘텐츠가 잘리지 않는다.
웹에서 PDF를 만드는 건 단순한 스크린 캡처가 아니라,
"A4 용지라는 물리적 제약 안에서 레이아웃을 수학적으로 설계하는 것"이다.
1️⃣ A4 높이를 px로 환산 : 컨테이너 너비 × √2 ≈ 911px
2️⃣ 영역별 높이 분배 : Header + Content + Footer = 911px
3️⃣ 아이템 높이 계산 : 한 줄 / 두 줄에 따라 행 높이가 다름
4️⃣ 페이지당 아이템 수 결정 : Content 높이 ÷ 행 높이 = 최대 문장 수
5️⃣ 데이터를 chunk로 분할 : 초과분은 다음 페이지로 넘김
이 과정을 거치면, 몇 페이지가 되든 모든 페이지가 동일한 구조를 유지하며
페이지가 넘어가는 지점에서도 콘텐츠가 잘리거나 어색하지 않은 PDF를 만들 수 있다.
CSS의 @media print나 break-inside: avoid 같은 속성으로도 어느 정도 제어할 수 있지만,
정밀한 페이지 분할이 필요한 리포트에서는 이처럼 직접 높이를 계산하는 방식이 훨씬 안정적이다.