Skip to content

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>

스타일 속성

이유

position: fixed

스크롤과 무관하게 화면 고정

z-index: -9999

화면 제일 뒤로 (보이지 않게)

opacity: 0

눈에 안 보이게

pointer-events: none

클릭 등 이벤트 차단

overflow: hidden

혹시 내부 넘침 방지

width/height

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