PageSourceSearch

https://ssonda.ai/assets/js/components/pdf-viewer.js;jsessionid=65…E916667A10FAE91BD7CF6D7B625?v=1790900721729

js ssonda.ai collected 2026-10-02 00:30:04 UTC 10,862 bytes, 209 lines download raw bytes

1/*
2    PDF 미리보기 어댑터
3
4    이 파일이 PDF 뷰어 구현을 감추는 유일한 경계다.
5    호출부(파일 컴포넌트·계약서 보기·계약 프로세스)는 아래 함수 하나만 알면 된다.
6
7        buildPdfPreviewHtml(fileUrl, height) -> PDF 미리보기 HTML 문자열
8
9    구현을 갈아끼울 때는 이 파일만 교체한다. 같은 시그니처로 HTML 문자열만 돌려주면
10    호출부는 손대지 않아도 된다. (브라우저 기본 iframe 으로 되돌리려면 USE_PDF_VIEWER = false)
11
12    현재 구현: EmbedPDF 2.15.0 — PDFium(WASM) 엔진, 페이지에 직접 렌더
13    - 자산 위치: /assets/lib/embedpdf (원본 무수정 vendoring, 버전은 VERSION.txt 참조)
14    - 엔트리가 ES 모듈이라 동적 import 로 불러온다. 최초 PDF 미리보기 시점에만 내려받는다.
15    - 앞서 쓰던 pdfjs-viewer-element(PDF.js)는 iOS 에서 못 썼다. Safari 18 미만은 로드 실패,
16      최신 iOS 도 첫 제스처 뒤 조작이 멎었다. EmbedPDF 는 구형 iOS 포함 정상 동작을 확인했다.
17    - wasmUrl 은 origin 을 붙인 절대 URL 로 준다. 루트상대경로는 워커에서 못 풀고, 아예 빼면 외부 CDN 을 쓴다.
18
19    usage: element.innerHTML = buildPdfPreviewHtml(fileUrl, "534px");
20*/
21const PDF_VIEWER_BASE = "/assets/lib/embedpdf";
22const USE_PDF_VIEWER = true;   // ← 롤백 스위치 (false 면 브라우저 기본 iframe)
23
24// 미리보기에 불필요한 기능은 UI·명령 양쪽에서 차단한다.
25// 저장 경로는 화면의 기존 다운로드 버튼으로 일원화하므로 내보내기·인쇄도 뺀다.
26const PDF_VIEWER_DISABLED = [
27    "annotation", "redaction", "form", "signature", "stamp", "insert", "capture", "history",
28    "document-export", "document-print", "document-open", "document-close",
29    "mode-annotate", "mode-shapes", "mode-redact", "mode-insert", "mode-form",
30    "panel-comment", "panel-annotation-style", "panel-redaction",
31    "document-menu"   // 보안·화면캡처·전체화면이 든 메뉴 (전체화면은 쪽 설정에 남는다)
32];
33
34// 뷰어는 shadow DOM 이라 바깥 CSS 가 닿지 않는다. 초기화 후 스타일을 직접 넣는다.
35// 문서 메뉴를 껐는데도 그 버튼 자리(폭 0)와 뒤따르는 구분선이 남아 툴바 맨 앞에 세로선이 생긴다.
36const PDF_VIEWER_SHADOW_CSS = [
37    // 문서 메뉴를 껐는데도 그 버튼 자리(폭 0)와 뒤따르는 구분선이 남아 툴바 맨 앞에 세로선이 생긴다
38    ".flex.items-center.gap-2.justify-start > div:nth-child(-n+2) { display: none; }",
39    // 좁은 폭에서 접히는 배율 버튼 아이콘을 돋보기+ 로 바꾼다.
40    // 내장 아이콘은 registerIcons 로 덮어쓸 수 없어 마스크로 그린다 (색은 currentColor 를 따른다)
41    // 빈 상태(문서 닫은 뒤) 화면의 "문서 열기" 버튼·지원형식 문구 숨김 — 미리보기에서 임의 파일 열기 차단.
42    // 카테고리 속성이 없는 버튼이라 disabledCategories 로는 안 막힌다. 빈 상태 카드만 h2 제목을 쓴다
43    ".max-w-sm:has(> h2) > button, .max-w-sm:has(> h2) > p.text-fg-muted { display: none !important; }",
44    '[data-epdf-i="zoom-menu-button"] button svg { display: none; }',
45    '[data-epdf-i="zoom-menu-button"] button::before {' +
46        'content: ""; width: 20px; height: 20px; background-color: currentColor;' +
47        '-webkit-mask: url("data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20viewBox%3D%270%200%2024%2024%27%20fill%3D%27none%27%20stroke%3D%27black%27%20stroke-width%3D%272%27%20stroke-linecap%3D%27round%27%20stroke-linejoin%3D%27round%27%3E%3Ccircle%20cx%3D%2711%27%20cy%3D%2711%27%20r%3D%277%27%2F%3E%3Cpath%20d%3D%27M20.5%2020.5l-4.7-4.7%27%2F%3E%3Cpath%20d%3D%27M11%208v6%27%2F%3E%3Cpath%20d%3D%27M8%2011h6%27%2F%3E%3C%2Fsvg%3E") center / contain no-repeat;' +
48        'mask: url("data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20viewBox%3D%270%200%2024%2024%27%20fill%3D%27none%27%20stroke%3D%27black%27%20stroke-width%3D%272%27%20stroke-linecap%3D%27round%27%20stroke-linejoin%3D%27round%27%3E%3Ccircle%20cx%3D%2711%27%20cy%3D%2711%27%20r%3D%277%27%2F%3E%3Cpath%20d%3D%27M20.5%2020.5l-4.7-4.7%27%2F%3E%3Cpath%20d%3D%27M11%208v6%27%2F%3E%3Cpath%20d%3D%27M8%2011h6%27%2F%3E%3C%2Fsvg%3E") center / contain no-repeat; }'
49].join(" ");
50
51// 배율 아이콘 교체 (기본은 원 안의 +/-). 좁은 폭에서 접히는 배율 메뉴도 zoomIn 아이콘을 쓴다.
52// path 데이터만 받는 구조라 SVG 마크업은 넣을 수 없다.
53const PDF_VIEWER_ICONS = {
54    zoomIn: {
55        strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round",
56        paths: [
57            { d: "M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z", stroke: "currentColor" },
58            { d: "M21 21l-4.35-4.35", stroke: "currentColor" },
59            { d: "M11 8v6", stroke: "currentColor" },
60            { d: "M8 11h6", stroke: "currentColor" }
61        ]
62    },
63    zoomOut: {
64        strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round",
65        paths: [
66            { d: "M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z", stroke: "currentColor" },
67            { d: "M21 21l-4.35-4.35", stroke: "currentColor" },
68            { d: "M8 11h6", stroke: "currentColor" }
69        ]
70    }
71};
72
73let pdfViewerLoading = null;
74let pdfViewerLocale = null;
75let pdfViewerObserved = false;
76
77/**
78 * PDF 뷰어 모듈 + 한국어 로케일 지연 로드 (중복 호출해도 1회만 로드)
79 */
80function ensurePdfViewer() {
81    if (pdfViewerLoading == null) {
82        pdfViewerLoading = Promise.all([
83            import(`${PDF_VIEWER_BASE}/embedpdf.js`),
84            import("/assets/js/components/pdf-viewer-locale-ko.js")
85        ]).then(([module, locale]) => {
86            pdfViewerLocale = { code: "ko", name: "한국어", translations: locale.default };
87            // 배율 아이콘 교체는 init 설정이 아니라 registry 등록으로 적용된다
88            if (typeof module.registerIcons === "function") {
89                module.registerIcons(PDF_VIEWER_ICONS);
90            }
91            observePdfViewerHosts();
92            return module;
93        }).catch((error) => {
94            // 뷰어를 못 쓰는 환경이면 브라우저 기본 미리보기로 되돌린다
95            console.warn("PDF 뷰어 로드 실패, 기본 미리보기로 대체", error);
96            document.querySelectorAll(".pdf-preview-host").forEach(fallbackPdfPreview);
97            throw error;
98        });
99    }
100    return pdfViewerLoading;
101}
102
103/**
104 * 미리보기 자리는 호출부마다 삽입 방식이 달라(문자열 innerHTML) 한 곳에서 관찰해 초기화한다.
105 * 관찰 대상이 .pdf-preview-host 하나뿐이라 기존 요소·타이밍에는 영향이 없다.
106 */
107function observePdfViewerHosts() {
108    if (pdfViewerObserved) {
109        return;
110    }
111    pdfViewerObserved = true;
112
113    initPdfViewerHosts(document);
114
115    new MutationObserver((mutations) => {
116        mutations.forEach((mutation) => {
117            mutation.addedNodes.forEach((node) => {
118                if (node.nodeType === Node.ELEMENT_NODE) {
119                    initPdfViewerHosts(node);
120                }
121            });
122        });
123    }).observe(document.documentElement, { childList: true, subtree: true });
124}
125
126function initPdfViewerHosts(root) {
127    let hosts = [];
128
129    if (root.matches != null && root.matches(".pdf-preview-host")) {
130        hosts.push(root);
131    }
132    if (root.querySelectorAll != null) {
133        hosts = hosts.concat(Array.from(root.querySelectorAll(".pdf-preview-host")));
134    }
135
136    hosts.forEach((host) => {
137        if (host.dataset.ready != null) {
138            return;
139        }
140        host.dataset.ready = "1";
141
142        ensurePdfViewer().then((module) => {
143            module.default.init({
144                type: "container",
145                target: host,
146                src: host.dataset.pdfSrc,
147                worker: true,
148                // wasm 경로는 origin 을 붙인다. 워커 안에서는 루트상대경로가 풀리지 않아
149                // 지정을 빼면 외부 CDN(jsDelivr)에서 받아온다
150                wasmUrl: `${location.origin}${PDF_VIEWER_BASE}/pdfium.wasm`,
151                // 페이지 배경이 흰색이라 뷰어는 다크로 대비를 준다 (라이트로 되돌리려면 preference: "light")
152                theme: { preference: "dark" },
153                icons: PDF_VIEWER_ICONS,
154                tabBar: "never",
155                disabledCategories: PDF_VIEWER_DISABLED,
156                fonts: { ui: null, signature: null },  // 외부(구글) 폰트 요청 차단
157                fontFallback: null,                    // 대체폰트 미사용 (표본 확인 후 결정)
158                stamp: { manifests: [] },              // 기본 스탬프 라이브러리(외부 CDN) 미사용
159                i18n: { defaultLocale: "ko", locales: [pdfViewerLocale] },
160                pan: { defaultMode: "mobile" },
161                zoom: { defaultZoomLevel: "fit-width" }   // 열자마자 폭에 맞춘다
162            });
163            injectPdfViewerShadowStyles(host);
164        }).catch(() => { /* ensurePdfViewer 에서 이미 대체 처리 */ });
165    });
166}
167
168/** shadow DOM 안에 스타일을 넣는다 (컨테이너가 붙을 때까지 잠깐 기다린다) */
169function injectPdfViewerShadowStyles(host, tries = 0) {
170    const container = host.querySelector("embedpdf-container");
171
172    if (container == null || container.shadowRoot == null) {
173        if (tries < 40) {
174            requestAnimationFrame(() => injectPdfViewerShadowStyles(host, tries + 1));
175        }
176        return;
177    }
178    if (container.shadowRoot.querySelector("style[data-app]") != null) {
179        return;
180    }
181
182    const style = document.createElement("style");
183    style.dataset.app = "1";
184    style.textContent = PDF_VIEWER_SHADOW_CSS;
185    container.shadowRoot.appendChild(style);
186}
187
188/** 뷰어를 못 쓸 때 브라우저 기본 미리보기로 되돌린다 */
189function fallbackPdfPreview(host) {
190    const height = host.style.height || "534px";
191    host.innerHTML = `<iframe src="${host.dataset.pdfSrc}" width="100%" height="${height}"></iframe>`;
192}
193
194/**
195 * PDF 미리보기 HTML 문자열 반환 (호출부는 동기 유지)
196 *
197 * @param {string} fileUrl 서버 파일 URL 또는 blob URL
198 * @param {string} height  뷰어 높이 (기본 534px)
199 */
200function buildPdfPreviewHtml(fileUrl, height = "534px") {
201    if (!USE_PDF_VIEWER) {
202        return `<iframe src="${fileUrl}" width="100%" height="${height}"></iframe>`;
203    }
204
205    ensurePdfViewer(); // 지연 로드 트리거 (삽입 후 옵저버가 초기화하므로 대기 불필요)
206
207    return `<div class="pdf-preview-host" data-pdf-src="${fileUrl}"
208                style="display: block; width: 100%; height: ${height};"></div>`;
209}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.