React:Examples:ExportPdfForHiddenComponents
숨겨놓은 React컴포넌트를 PDF 로 저장하는 예시.
미디어 쿼리 안쓰는 이유
@media print
는 브라우저 인쇄 기능(Ctrl+P)을 사용했을 때만 작동한다.
따라서 별도의 프린트 팝업 없이 바로 PDF 다운로드 하고싶다면 아래와 같이 진행해야 한다.
iframe 사용하지 않는 이유
- iframe은 완전히 독립된 문서예요.
- React 컴포넌트는 Virtual DOM 안에서 작동하기 때문에, 다른 DOM(DocumentContext)에 직접 삽입하려면 별도의 렌더링 방식이 필요해요.
- iframe은 CSS 격리가 되기 때문에, 앱의 전역 스타일이나 테마가 iframe에 적용되지 않아요.
- 따로 CSS를 로딩하거나 인라인으로 전달해줘야 함.
- props, context, 상태 등을 iframe으로 넘기기 위해선 postMessage, JSON serialization 등 추가적인 작업 필요.
- 간단하게 컴포넌트를 넘길 수 없음.
숨겨 놓은 HTML 요소 만들기
<div
ref={componentRef}
style={{
position: "fixed",
top: 0,
left: 0,
width: "210mm",
height: "297mm",
zIndex: -9999,
opacity: 0,
pointerEvents: "none",
overflow: "hidden",
}}
>
<OtherPageComponent />
</div>
스타일 속성 | 이유 |
| 스크롤과 무관하게 화면 고정 |
| 화면 제일 뒤로 (보이지 않게) |
| 눈에 안 보이게 |
| 클릭 등 이벤트 차단 |
| 혹시 내부 넘침 방지 |
| PDF 출력에 맞는 정확한 크기 |
Export PDF 아이콘 다운로드
https://icones.js.org/ 에서 적당한 아이콘 다운로드.
import {SVGProps} from 'react';
export function BiFileEarmarkPdf(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 16 16"
{...props}
>
<g fill="currentColor">
<path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2M9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z"></path>
<path d="M4.603 14.087a.8.8 0 0 1-.438-.42c-.195-.388-.13-.776.08-1.102c.198-.307.526-.568.897-.787a7.7 7.7 0 0 1 1.482-.645a20 20 0 0 0 1.062-2.227a7.3 7.3 0 0 1-.43-1.295c-.086-.4-.119-.796-.046-1.136c.075-.354.274-.672.65-.823c.192-.077.4-.12.602-.077a.7.7 0 0 1 .477.365c.088.164.12.356.127.538c.007.188-.012.396-.047.614c-.084.51-.27 1.134-.52 1.794a11 11 0 0 0 .98 1.686a5.8 5.8 0 0 1 1.334.05c.364.066.734.195.96.465c.12.144.193.32.2.518c.007.192-.047.382-.138.563a1.04 1.04 0 0 1-.354.416a.86.86 0 0 1-.51.138c-.331-.014-.654-.196-.933-.417a5.7 5.7 0 0 1-.911-.95a11.7 11.7 0 0 0-1.997.406a11.3 11.3 0 0 1-1.02 1.51c-.292.35-.609.656-.927.787a.8.8 0 0 1-.58.029m1.379-1.901q-.25.115-.459.238c-.328.194-.541.383-.647.547c-.094.145-.096.25-.04.361q.016.032.026.044l.035-.012c.137-.056.355-.235.635-.572a8 8 0 0 0 .45-.606m1.64-1.33a13 13 0 0 1 1.01-.193a12 12 0 0 1-.51-.858a21 21 0 0 1-.5 1.05zm2.446.45q.226.245.435.41c.24.19.407.253.498.256a.1.1 0 0 0 .07-.015a.3.3 0 0 0 .094-.125a.44.44 0 0 0 .059-.2a.1.1 0 0 0-.026-.063c-.052-.062-.2-.152-.518-.209a4 4 0 0 0-.612-.053zM8.078 7.8a7 7 0 0 0 .2-.828q.046-.282.038-.465a.6.6 0 0 0-.032-.198a.5.5 0 0 0-.145.04c-.087.035-.158.106-.196.283c-.04.192-.03.469.046.822q.036.167.09.346z"></path>
</g>
</svg>
);
}
export default BiFileEarmarkPdf;
툴팁이 적용된 아이콘 버튼 적용:
<div
className="tw-group tw-relative tw-flex tw-justify-center tw-items-center"
onClick={e => onClickExportPdf(e, d)}
>
<BiFileEarmarkPdf className="tw-text-center tw-text-lg tw-m-1" />
<span className="group-hover:tw-opacity-70 tw-transition-opacity tw-bg-gray-500 tw-py-1 tw-px-2 tw-text-xs tw-text-gray-100 tw-rounded-md tw-absolute tw-left-1/2 -tw-translate-x-1/2 tw-translate-y-full tw-opacity-0 tw-m-4 tw-mx-auto">
PDF
</span>
</div>
적당한 위치에 버튼과 이벤트 핸들러 추가
import BiFileEarmarkPdf from '../icons/BiFileEarmarkPdf';
import styles from './DailyWorkStatus.module.scss';
// ...
export default function DailyWorkStatus() {
// ...
const pdfLayoutRef = useRef<HTMLDivElement>(null);
const onClickExportPdf = (
e: React.MouseEvent<SVGSVGElement, MouseEvent>,
data: DailyInfoData
) => {
e.stopPropagation();
};
// ...
<BiFileEarmarkPdf onClick={e => onClickExportPdf(e, d)} />
// ...
<div ref={pdfLayoutRef} className={styles.pdfLayout}></div>
// ...
}
실제 코드
const pdfLayoutRef = useRef<HTMLDivElement>(null);
const [selectedDailyInfo, setSelectedDailyInfo] = useState<DailyInfoData>();
const [grapples, setGrapples] = useState<DailyDetailGrapplesData[]>();
const [workBrief, setWorkBrief] = useState<DailyInfoBrief>();
const [raw, setRaw] = useState<DailyInfoRaw>();
const [grappleImageSrcs, setGrappleImageSrcs] = useState<string[]>([]);
const onClickExportPdf = async (
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
data: DailyInfoData
) => {
e.stopPropagation();
if (selectedDailyInfo) {
return;
}
setSelectedDailyInfo(data);
try {
const vehicleIdx = data.vehicleIdx;
const briefResp = await getData('work/daily/detail/info/work', {vehicleIdx});
const workBrief = briefResp as DailyInfoBrief;
const grapplesResp = await getData('work/daily/detail/grapples', {vehicleIdx});
const grapples = grapplesResp as DailyDetailGrapplesData[];
const imagePromises = grapples?.map(async grapple => {
const imageData = await getImage(`${grapple.originImage}`, `true`);
const blob = await imageData.blob();
return URL.createObjectURL(blob);
});
const imageSrcs = await Promise.all(imagePromises);
const rawResp = await getData(`statistics/scraps`, {vehicleIdx});
const raw = rawResp as DailyInfoRaw;
setGrapples(grapples);
setWorkBrief(workBrief);
setRaw(raw);
setGrappleImageSrcs(imageSrcs);
} catch (e) {
console.error(e);
setGrapples(undefined);
setWorkBrief(undefined);
setRaw(undefined);
setGrappleImageSrcs([]);
} finally {
setSelectedDailyInfo(undefined);
}
};
const generatePDF = async () => {
if (!pdfLayoutRef.current) {
return;
}
try {
const canvas = await html2canvas(pdfLayoutRef.current, {
scale: 2.0,
useCORS: true,
logging: false,
});
const imgData = canvas.toDataURL('image/png');
const pdf = new jspdf({
orientation: 'p',
unit: 'px',
format: [canvas.width, canvas.height],
});
pdf.addImage(imgData, 'PNG', 0, 0, canvas.width, canvas.height);
pdf.save('document.pdf');
} catch (error) {
console.error('PDF 생성 중 오류:', error);
}
};
useEffect(() => {
if (!pdfLayoutRef.current) {
return;
}
if (!(grapples && workBrief && raw && grappleImageSrcs)) {
return;
}
const timer = setTimeout(async () => {
await generatePDF();
}, 100);
return () => clearTimeout(timer);
}, [grapples, workBrief, raw, grappleImageSrcs]);
const DownloadPdfIcon = ({cursor}: {cursor: DailyInfoData}) => {
if (!selectedDailyInfo) {
return (
<div
className="tw-group tw-relative tw-flex tw-justify-center tw-items-center"
onClick={e => onClickExportPdf(e, cursor)}
>
<BiFileEarmarkPdf className="tw-text-center tw-text-lg tw-m-1" />
<span className="group-hover:tw-opacity-70 tw-transition-opacity tw-bg-gray-500 tw-py-1 tw-px-2 tw-text-xs tw-text-gray-100 tw-rounded-md tw-absolute tw-left-1/2 -tw-translate-x-1/2 tw-translate-y-full tw-opacity-0 tw-m-4 tw-mx-auto">
PDF
</span>
</div>
);
} else if (selectedDailyInfo === cursor) {
return (
<div
className="tw-group tw-relative tw-flex tw-justify-center tw-items-center"
onClick={e => e.stopPropagation()}
>
<SvgSpinnersRingResize className="tw-text-center tw-text-lg tw-m-1" />
<span className="group-hover:tw-opacity-70 tw-transition-opacity tw-bg-gray-500 tw-py-1 tw-px-2 tw-text-xs tw-text-gray-100 tw-rounded-md tw-absolute tw-left-1/2 -tw-translate-x-1/2 tw-translate-y-full tw-opacity-0 tw-m-4 tw-mx-auto">
Loading
</span>
</div>
);
} else {
return (
<div
className="tw-relative tw-flex tw-justify-center tw-items-center"
onClick={e => e.stopPropagation()}
>
<BiFileEarmarkPdf className="tw-text-center tw-text-gray-500/50 tw-text-lg tw-m-1" />
</div>
);
}
};
페이지 단위로 나누는 코드 변경
const generatePDF = async () => {
if (!pdfLayoutRef.current) {
return;
}
try {
const pdf = new jspdf({orientation: 'p', unit: 'mm', format: 'a4'});
const pageWidth = 210;
const pageHeight = 297;
const pages = pdfLayoutRef.current.children;
for (let i = 0; i < pages.length; i++) {
const page = pages[i] as HTMLElement;
const canvas = await html2canvas(page, {scale: 2.0});
const imgData = canvas.toDataURL('image/png');
if (i > 0) {
pdf.addPage('a4', 'p');
}
pdf.addImage(imgData, 'PNG', 0, 0, pageWidth, pageHeight);
}
pdf.save('Document.pdf');
} catch (error) {
console.error('PDF 생성 중 오류:', error);
}
};
See also
- React
- react-pdf - React renderer for creating PDF files on the browser and server.
- react-to-pdf
- html2canvas - HTML 랜더링 결과를 Canvas 로 복사.
- jsPDF