PDF…삽질의 기록…
05/20 발표 - 이든/삽질의 기록…Permalink
1. PDF 너… 이자식…!Permalink
문제Permalink
PDF가 일부만 보여지고 짤려서 보임
https://jira.daumkakao.com/browse/HCMONEYBALL-4240
여기는 두개 문제가 있었습니다.
1) ‘일부만’ ⇒ pdf가 한 페이지까지만 생성됨
// pdf.ts
const doc = new jsPDF("p", "mm", "a4", true);
const paperElements = document.querySelectorAll(
`.preview-page`
) as NodeListOf<HTMLElement>;
// 페이지 출력
for (let el of paperElements) {
const canvas = await html2canvas(el, { scale: 2 });
const imgData = canvas.toDataURL("image/png");
const imgWidth = 210; // 이미지 가로 길이(mm) A4 기준
const imgWidthRatio = 210 / canvas.width;
const pageHeight = imgWidthRatio * canvas.height;
doc.addPage();
doc.addImage(imgData, "jpeg", 0, 0, imgWidth, pageHeight);
}
doc.deletePage(1); // 첫번째 생성 이미지 삭제
기존의 로직에서는 addPage()로 페이지를 만들고 image를 추가해 줍니다.
이를 a4사이즈만큼 여러장으로 만들려면 이미지가 a4 사이즈를 넘어갈 때 잘라 주면 됩니다.
for (let el of paperElements) {
const canvas = await html2canvas(el, { scale: 2 });
const imgData = canvas.toDataURL("image/png");
const imgWidth = 210;
const pageHeight = 297;
const imgHeight = (canvas.height * imgWidth) / canvas.width;
let heightLeft = imgHeight;
let position = 0;
doc.addImage(imgData, "jpeg", 0, 0, imgWidth, imgHeight);
heightLeft -= pageHeight;
while (heightLeft >= 0) {
position = heightLeft - imgHeight;
doc.addPage();
doc.addImage(imgData, "jpeg", 0, position, imgWidth, imgHeight);
heightLeft -= pageHeight;
}
}
- ‘짤려서’ ⇒ 요소 중간을 짤라버림
이친구가 사곤데…
위의 방법으로 여러 페이지에 데이터를 나눠도… 짤리는건 동일합니다.
PastaConnect_Report_20240518_1834.pdf
문제의 원인Permalink
1)은 패스하고 2)문제의 원인을 살펴보면 우리가 pdf를 만드는 방식은
1️⃣ preview html 요소(+ag-grid) 렌더링
2️⃣ html2canvas에서 DOM에서 읽은 속성을 스크린 샷찍음
3️⃣ jsPdf를 통해 2️⃣에서 찍은 이미지를 pdf로 만듬
이러한 방식으로 동작합니다.
1)에서 시도한 방식은 2️⃣ → 3️⃣ 시에 a4사이즈만큼 이미지를 단순히 자른 것이기 때문에 요소가 짤려서 나오는 것입니다.
이런 현상을 우리만 겪었을리 없다..!라는 희망으로 구글링 중 jsPdf에서 한가지 이슈를 발견했습니다.
https://github.com/parallax/jsPDF/issues/1699
어어어…!!!!
html2pdf 너라면..?Permalink
이거다..! 하고 도파민 터지며 html2pdf를 열심히 찾았습니다.
찾은 html2pdf.js 라이브러리에서 해당 내용을 확인하고 신나서 바로 적용을 해봤습니다.
https://ekoopmans.github.io/html2pdf.js/#page-breaks
급하게 먼저 페이지가 제대로 나눠지는지 확인하기 위해서 MealPattern
에만 바로 적용해봤습니다.
심지어 사용법도 매우 간단했습니다!!!!!! (도파민 폭팔.)
const paperElements = document.querySelectorAll(
`.preview-content-2`
) as NodeListOf<HTMLElement>;
const opt = {
filename: `PastaConnect_Report_${dayjs().format("YYYYMMDD_HHmm")}.pdf`,
image: { type: "jpeg" },
html2canvas: { scale: 2 },
jsPDF: { unit: "mm", format: "a4", orientation: "portrait", compress: true },
pagebreak: { mode: "avoid-all" },
};
const doc = html2pdf().from(paperElements[0]).set(opt);
바로 적용해보니
흐흐흐흫,,ㅎㅎ
이게 되네??
나눠진다!!!!!!
하지만 오른쪽에 이미지가 깨지는 현상이 있습니다.
이정도야 뭐~~ 이미지 a4사이즈로 맞추면 되겠지~~~~~(행복회로 풀가동)하고 사이즈를
이미지의 사이즈를 바꿔봤지만 jsPdf의 addImage와는 다르게 이미지를 jsPdf format에 맞춰 압축이 되지 않았습니다..
그러던 도중 html2pdf.js에서 이슈하나를 만나게 되었습니다,,,
https://github.com/eKoopmans/html2pdf.js/issues/6
위의 이슈 내용을 요약하자면
이미지를 pdf에 맞춰서 줄이고 싶다 → 만들 예정이다 하지만 아직은 아니다 (2017.05)
그리고 7년이 지난 2024년의 저는 이제서야 도파민에 중독되서 보이지 않던 게 보이기 시작했습니다
??
아.. 버려진 라이브러리구나..
아안대.>!!
절망하던 찰나에 img → pdf로 사이즈를 맞출 수 없다면 pdf → img로 사이즈를 맞춘다면??
라는 생각으로 pdf를 a4사이즈가 아닌 img에 width 사이즈에 맞추고 a4 비율만큼 height를 정해서 나줘줬습니다.
흐음…
그렇게 반쪽 짜리 해결을 하고 1차전을 마무리 했습니다.
그래도 얻은 건 있습니다.
- ag-grid의 요소별로 page-break를 할 수 있다는 희망
- page-break를 해주는 건 js가 아니라 css의 page-break 속성값으로 결정한다는 정보
그리고 거스, 제로에게 공유 후 제로와 함께 2차전을 시작했습니다.
2차전 시작Permalink
1차전에서 얻은 정보를 토대로 2차전에서 해볼 시도들을 정리해봤습니다.
시도해보기Permalink
- css의 page-break 속성 동작방식 알아보기
- html2pdf 소스코드를 까보기
- 새로운 방식의 접근
page-break 트리거Permalink
구 page-break-inside 현 break-inside 속성은 해당 요소 내에서 페이지 나누기, 열 나누기 같은 영역 나누기가 발생해야 하는지 여부를 지정합니다.
page-break-inside : avoid;
break-inside : avoid;
를 하면 페이지를 나눌 때 요소가 걸쳐있으면 나눠지지 않고 다음 페이지에 온전히 넘어오게 됩니다.
출처 : http://www.mins01.com/mh/tech/read/1360
MDN설명 : https://developer.mozilla.org/en-US/docs/Web/CSS/break-inside
더 확실한 설명을 위해 CSS 스팩을 찾아보면
참고(CSS 스팩): https://drafts.csswg.org/css-break/
위와 같이 설명이 되어있습니다. 딱 우리가 원하는 기능이죠ㅠㅠ
이 속성이 적용되는 breaking point도 찾아볼 수 있었습니다.
참고(CSS 스팩): https://drafts.csswg.org/css-break/
요약하자면 조각화는 블록 및 인라인 차원에서만 분할이 되며
가능한 중단점은 블록 컨테이너 사이가 페이지를 나눌 때, 열을 나눌 때, 지역을 구분할 때(지역은 무슨말인지 모르겠어요…)입니다.
그리고 이러한 조각화가 적용되지 않는 경우는
- 이미지, 비디오
- 스크롤 가능한 요소
- 한 줄의 텍스트
- 인라인 블록, 인라인 테이블 상자
- 너무 큰 요소
등이 있습니다.
ag-grid에서도 이를 해결하기 위해 domLayout이라는 속성이 있고 print 값을 넣어주면 스크롤이 풀리며 print용으로 레이아웃을 변경하는 식으로 해결했습니다.
화면 기록 2024-05-18 오후 9.39.06.mov
ag-grid의 page-break : https://www.ag-grid.com/react-data-grid/printing/#page-break
html2pdf 소스코드를 까보기Permalink
html2pdf라는 버려진 라이브러리를 사용할 수는 없기 때문에 어떻게 문제를 해결해 왔는지만 참고해서 합쳐보려고 했습니다.
page-break를 하는 핵심 로직은 이부분입니다.
Worker.prototype.toContainer = function toContainer() {
return orig.toContainer.call(this).then(function toContainer_pagebreak() {
// Setup root element and inner page height.
var root = this.prop.container;
var pxPageHeight = this.prop.pageSize.inner.px.height;
// Check all requested modes.
var modeSrc = [].concat(this.opt.pagebreak.mode);
var mode = {
avoidAll: modeSrc.indexOf("avoid-all") !== -1,
};
// Get all legacy page-break elements.
var legacyEls = root.querySelectorAll(".html2pdf__page-break");
legacyEls = Array.prototype.slice.call(legacyEls);
// Loop through all elements.
var els = root.querySelectorAll("*");
Array.prototype.forEach.call(els, function pagebreak_loop(el) {
// Setup pagebreak rules based on legacy and avoidAll modes.
var rules = { avoid: mode.avoidAll };
// Add rules for css mode.
if (mode.css) {
rules = { avoid: rules.avoid };
}
// 뷰포트와 상대적 위치정보 제공
var clientRect = el.getBoundingClientRect();
// Avoid: Check if a break happens mid-element.
if (rules.avoid && !rules.before) {
var startPage = Math.floor(clientRect.top / pxPageHeight);
var endPage = Math.floor(clientRect.bottom / pxPageHeight);
var nPages =
Math.abs(clientRect.bottom - clientRect.top) / pxPageHeight;
// Turn on rules.before if the el is broken and is at most one page long.
if (endPage !== startPage && nPages <= 1) {
rules.before = true;
}
}
// Before: Create a padding div to push the element to the next page.
if (rules.before) {
var pad = createElement("div", {
style: {
display: "block",
height: pxPageHeight - (clientRect.top % pxPageHeight) + "px",
},
});
el.parentNode.insertBefore(pad, el);
}
});
});
};
그리고 html2pdf가 작동하는 순서를 보면
.toContainer() 이후 .toCanvas()를 하는 것을 알 수 있습니다.
미리 요소를 짤리지 않게 페이지를 나눈 후(패딩을 주는식으로 format에 맞춤) canvas로 그려내고 pdf를 만드는 식으로 작동합니다.
새로운 방식의 접근Permalink
먼저 ag-grid에 있는 domLayout = "print"
속성이 어떻게 작동하는지를 까보니
ag-layout-print라는 클래스를 추가하여
css 속성을 변경하는 식으로 작동했습니다.
/*ag-grid*/
.ag-layout-print {
&.ag-body {
display: block;
height: unset;
}
&.ag-root-wrapper {
/* this has to be inline-block otherwise the grid gets constrained by
the viewport (AG-5616) and other values like `inline-flex` will add a
small gap at the top (AG-9296) */
display: inline-block;
}
.ag-body-vertical-scroll {
display: none;
}
.ag-body-horizontal-scroll {
display: none;
}
&.ag-force-vertical-scroll {
overflow-y: visible !important;
}
}
@media print {
.ag-root-wrapper.ag-layout-print {
display: table;
.ag-root-wrapper-body,
.ag-root,
.ag-body-viewport,
.ag-center-cols-container,
.ag-center-cols-viewport,
.ag-body-horizontal-scroll-viewport,
.ag-virtual-list-viewport {
/* Need auto height because 100% height elements with overflow hidden
cause printing issues in Edge */
height: auto !important;
/* Overflow hidden, because otherwise scroll bars print in IE */
overflow: hidden !important;
/* flex elements cause printing issues in Firefox
https://bugzilla.mozilla.org/show_bug.cgi?id=939897 */
display: block !important;
}
.ag-row,
.ag-cell {
break-inside: avoid;
}
}
}
여기서도 알 수 있듯, 결국 page-break를 해주기 위해서 break-inside, display, height, overflow 속성을 변경하는 식으로 구현하였습니다…ㅜㅜ
ag-grid커뮤니티에 pdfmake라이브러리와 함께 구현한 예시가 있습니다.
Exporting AG Grid to PDF with pdfMake
이것도 작동 방식을 보니 ag-grid의 값들을 가공해서 개별 페이지 만큼 자른 후 document를 생성 후 pdfMake에게 보내서 pdf를 만듭니다.
여러 삽질 끝에 얻은 결론은…
결국 ‘이미지로 만들기 전’ or ‘pdf로 만들기 전’에 정해진 영역만큼 나눠야지만 짤림현상을 해결할 수 있다… 라는 결론이 나왔습니다.
그 과정에서 css 스팩에 명시되어있는 page-break 중단점 부분을 살펴보면 페이지 뿐만 아니라 우리가 정한 기준으로 나눌 수 있지 않을까? 하는 기대가 있습니다,,,🥹
2. next-i18next에게 뒷통수 맞은 썰Permalink
next-i18next에서 설정바꾸면 accept-language 기준으로 locale정할 수 있다고 했지만… locale만 바뀔뿐 lang는 url을 따라갑니다ㅜ (해준다메에에에!!!!)
에잉 쯧쯧쯧,,,
문제Permalink
url에 ja를 붙이지 않으면 일어 폰트가 깨지는 현상(body에 font 적용이 안됨)
폰트가 깨지는 상황을 정리하면
url included | 브라우저 언어 | 초기렌더링 lang | 이후 렌더링 lang | ||
---|---|---|---|---|---|
case1 | ja | ja | en | ja | 폰트깨짐 |
case2 | ja | ko | en | ja | |
case3 | ko | ja | en | ko | 폰트깨짐 |
case4 | ko | ko | en | ko |
case1, 3인경우에 깨지는 것을 알 수 있습니다.
원인Permalink
폰트가 깨지는 이유는 html lang가 제대로 입력 되지 않았기 때문이었습니다!! (대장님짱)
이를 해결하기 위해 두가지 문제를 해결해야 했습니다.
- 초기 렌더링 시 폰트 적용 안되는 현상
: _ducument.tsx에 html lang가 en으로 고정되어있었고, next-i18next가 작동하는 시점이 _document 이후 이기 때문에 초기 렌더링 시에 accept-language
가 적용이 안됐습니다.
- 이후 렌더링 시 적용 안되는 현상
현재 구성되어있는 next-i18next에서 언어를 변경하는 로직이 sub-path Routing을 따르며 자동 로케일 감지를 하고 있습니다.(localeDetection: true
(기본값))
이게 동작하는 방식이 애플리케이션 ‘루트’를 방문했을 때 accept-language
를 감지하고
-
locales
배열 안에 있는 값이 있으면 locale이 붙은 경로로 리다이렉션 시켜주며 이후 하위 경로 라우팅합니다. -
locales
배열 안에 없을 경우defaultLocale
로 리다이렉션됩니다. 이때 각각 페이지에서 SSR, SSG를 통해 locales은 맞췄지만lang
는defaultLocale
그대로 따라갑니다.
테스트 결과 SSR시에는 accept-language를 따라 lang가 결정되지만 하이드레이션 이후 url을 따라 lang가 변경 됩니다.(url에 ko, en, ja 가 포함안되어있으면 deafultLocale
값인 ko 선택)
이때 의료진 모드의 각각 환자 정보를 들어갈 경우 router를 통한게 아닌 window.open으로 새창이 열리며 로케일이 풀립니다.
⇒ 현재 임시로 window.open을 accept-language
기준으로 로케이션이 붙은 url로 보내도록 util화해 해결한 상태입니다.
결과Permalink
원인1) 초기 렌더링 시에 lang에 accept-language
적용
이를 위해 _document가 렌더링 시 lang를 적용하기 위해서 lang가 accept-language
에 따라 바뀌도록 동적으로 변경했습니다.
(참고 : https://nextjs.org/docs/pages/building-your-application/routing/custom-document)
적용 후 결과는 아래와 같습니다.
- lang=”ko” : ko폰트적용
- lang=”ja” : ja폰트적용
url included | 브라우저 언어 | 초기렌더링 | 이후 렌더링 | ||
---|---|---|---|---|---|
case1 | ja | ja | lang=”ja” | lang=”ja” | |
case2 | ja | ko | lang=”ko” | lang=”ja” | |
case3 | ko | ja | lang=”ja” | lang=”ko” | 폰트깨짐 |
case4 | ko | ko | lang=”ko” | lang=”ko” |
case1, 4는 정상이고
case2는 한국어에서 ja폰트를 사용할 때는 jp가 없어서 Pretendard로 적용이 되어 문제가 없지만
case3에서 일어로 렌더링 되는데 lang=”ko”로(ko폰트) 적용되서 깨지는 현상이 남아있습니다.
이 경우가 원인 2)에 해당합니다.
그럼 어디서 어떻게 lang가 바뀌는지를 보면…
_document ⇒ accept 기준
_app / server ⇒ accept 기준
_app / client ⇒ accept 기준
각 하위페이지 server ⇒ accept 기준
각 하위페이지 client ⇒ url 기준
문제 상황 : 하이드렉션 이후 client에서 인터렉션 하면 url 기준으로 lang가 정해진다.
시도
-
layout으로 감싸서 document.lang 변경 ⇒ 실패
-
각페이지에서 docuemnt.lang 변경 ⇒ 랜덤하게 성공(순서 꼬이는듯)
이것저것 별 짓 다해봤지만,,, next-i18next는 url 기반으로 동작하기 때문에 수정할 수 없을 것 같다는 결론이 나왔습니다… (각 페이지에서 document.lang도 바꿔봤지만 랜덤하게 적용되며 깜빡거린다ㅜ )
제가 생각한 결론은 아래와 같습니다.
- window.open ⇒ router.push로 변경
이렇게 하면 행복하지만, 새창의 띄운 히스토리를 몰라서 제가 바꾸기가 애매해 냄겨뒀슴돠,,ㅎ,,
⇒ 추후 로케일을 뺄 것이기 때문에 반려.
- window.open 시 로케일 탐색
이렇게 할 경우 모든 곳에 적용을 위해 window.open을 감싸서 utils화 하는게 좋을 것 같습니다!!
⇒ 현재 방법 window.open 사용시 accept-language에서 locale을 검색하여 새창을 띄울 url 앞에 추가해주는 식으로 감싸서 사용하는 것을 의미했습니다!
const windowOpen = (url: string) => {
const lang = navigator.languages[0]; //사용자가 브라우저 설정에서 지정한 선호 언어 목록 중 1위
const newWindowURL = `/${lang}${url}`;
window.open(newWindowURL);
};
- next-i18next에서 accept-langue를 탐색하도록 변경
현재 이렇게 동작을 하는 것으로 알고있는데 lang
가 defaultLocale
을 따라가는 거 보니 lang는 루트에서 만 accept-language
에 따라 변경 후 하위 라우팅에서는 url을 따르는게 아닐까 의심됩니다.(관련 자료를 못찾았어요ㅜ )
- next-i18next ⇒ next-translate 로 이사가기
⇒ 유력 후보
기본적으로 next-i18next
는 url을 기준으로 locale
을 정합니다. 우리는 이거를 accept-language
를 기준으로 하도록 설정해서 사용하는 것이었구요
비슷한 기능을하는 국제화 라이브러리인 next-translate
는 accept-language
를 기준으로 loacle
을 정합니다.
이후 url에서 locale
을 뺄 예정이라면 next-translate
로 마이그레이션 하는게 좋을 듯합니다(좀 더 찾아보고 테스트 후에,,,)
이렇게 하면 유저가 루트(/)에서 접근했을 경우 locale이 항상 붙어있어서 임의로 url을 수정하지 않는 이상 locale이 항상 붙어있어서 정상적으로 작동합니다.
3. 다음 스터디 발표 스포Permalink
겪고 있는 문제점Permalink
저에게 온 티켓들을 구현하면서 애매함을 느낄때가 있었습니다.
- 새로만든 컴포넌트를 어디에 넣지?
- 내가 이거 수정하면 어디 터지는거 아니야..?
- A의 동작을 어디서 확인해야 하지..?
규모가 커질수록 개개인이 구현한 기능과 연관된 코드를 탐색하는 것도 어려워 질 것이고,
특정 도메인에 사용되는 코드들이 흩어져 있으면 찾기 힘들 것입니다.
내가 수정하는 코드가 다른 사람이 미리 만들어 놓은 기능에 어디까지 영향을 끼치며 버그를 불러일으키는지 확신할 수 없기 때문에 두려움을 안고 수정하게 됩니다.
여기에 알맞은 FSD 아키텍쳐를 발견했습니다.
FSD(Feature-sliced Design)Permalink
FSD는 FE앱을 스캐폴딩하기 위한 아키텍처 방법론입니다.
BasePermalink
FSD의 구성은 크게 Layers, Slices, Segments로 이루어져있습니다.
각각의 역할은
LayersPermalink
-
app
— 앱 전체 설정, 스타일 및 공급자.
-
pages
— 엔터티, 기능 및 위젯으로 전체 페이지를 구성하는 구성 레이어입니다.
-
widgets
— 엔터티와 기능을 의미 있는 블록으로 결합한 독립적인 UI (예: IssuesList, UserProfile)
-
features
— 사용자 상호 작용, 사용자에게 비즈니스 가치를 제공하는 기능 (예: SendComment, AddToCart, UsersSearch)
-
entities
— 비즈니스 엔티티(예: 사용자, 제품, 주문)
-
shared
— 프로젝트/비즈니스의 세부 사항과 분리된 재사용 가능한 기능입니다. (예: UIKit, libs, i18n, API)
** 비즈니스와 분리된 곳이기 때문에 slices 단계가 없습니다.
SlicesPermalink
비즈니스 도메인 별로 분리된 폴더이며 특정 비즈니스 엔티티에 대한 것으로 그룹화 한 것입니다.
동일한 layer에 다른 slice를 사용할 수 없다 는 규칙을 갖고 있습니다. (<= 높은 응집력, 낮은 결합도)
SegmentsPermalink
기술적 목적에 따라 slice내 코드를 분리하는데 도움을 주는 작은 모듈입니다.
몇 가지 표준화된 segment 로는 아래와 같이 있습니다.
-
ui
— UI 구성 요소, 데이터 형식 지정 기능
-
model
— 비즈니스 로직, 데이터 저장 및 이 데이터를 조작하는 기능
-
lib
— 보조 및 인프라 코드
-
api
— 외부 API, 백엔드 API 메소드와의 통신
이 아키텍쳐의 핵심은
- 자신보다 낮은 layer의 코드만 가져올 수 있다. (추상화, 다형성, 상속)
- 공개 API를 사용해서만 import 가능하다. (캡슐화)
를 통해
코드는 영향 범위(레이어), 도메인(슬라이스), 기술적 목적(세그먼트)별로 구성되고
이는 신규 사용자가 쉽게 이해할 수 있는 표준화된 아키텍처를 생성합니다. (라고 공식 문서에서 말합니다)
그리고 우리가 생각하고 있는 도메인 별 분리(slice가 대체) / 디자인 시스템 개선(shared에 ui키트) 시에도 시너지가 날 것 같습니다.
하지만 아토믹 디자인때도 그랬듯 이상과 현실을 다르기때문에 조금이라도 써보고 실제 사용 경험을 토대로 주장하는 것이 맞다고 생각합니다.
그래서 다음 발표(06/17)는 지금 구현중인 처방전을 완료 후 FSD로 마이그레이션 해보겠습니다…!
마이그레이션 전략Permalink
기존의 서비스에서 FSD로 넘어가기 위해서는 점진적인 채택이 불가피 합니다.
공식문서의 추천방법은
- 가장 작은 규모부터 채워나간다.(1st. app 2nd. shared)
- FSD 규칙을 위반 하더라도 일단 기존 UI를 모두 widgets에 박는다.
- features와 entities를 분리하고 page와 widgets을 로직이 있는 layer에서 pure한 layer로 옮긴다.
리팩토링 기간에 새로운 대규모 엔티티를 추가하지 않는게 좋다…
⇒ 큰 작업이 걸려있지 않을 때 시도해보자!!
참고Permalink
공식문서 : https://feature-sliced.design/docs/get-started/overview
공식문서(튜토리얼) : https://feature-sliced.design/docs/get-started/tutorial
정리본0 : https://www.youdad.kr/feature-sliced-design/#google_vignette
정리본1(한국어 번역) : https://emewjin.github.io/feature-sliced-design/
정리본2(단테): https://velog.io/@jay/fsd
예시(react-query) : https://github.com/ani-team/github-client
마치며…Permalink
주저리주저리 썼지만 쓰다보니 무엇하나 완벽하게 마무리 한 게 없다는 아쉬움만 남네요🥹
다음 발표에서는 고난과 역경뿐만 아니라 해결사례도 함께 담을 수 있기를 소망합니다,,,
댓글남기기