메인 항목으로

test

BookStack PDF Export 전처리 파이프라인 분석

대상: BookStack v25.02.4 커스터마이징 참여 개발자 범위: Mermaid 다이어그램이 포함된 페이지의 PDF 내보내기 경로 최종 수정: 2026-03-24


1. 개요

BookStack의 PDF 내보내기는 WeasyPrint(CSS 기반 PDF 렌더링 엔진)를 사용한다. WeasyPrint는 브라우저가 아니기 때문에 JavaScript를 실행할 수 없고, SVG 내부의 <foreignObject>도 지원하지 않는다. 이 제약이 Mermaid 다이어그램 PDF 출력의 핵심 문제였으며, 최종적으로 PNG 이미지 방식으로 해결했다.

이 문서는 PDF export 요청이 들어왔을 때 HTML이 어떤 전처리를 거쳐 WeasyPrint에 도달하는지를 단계별로 분석한다.


2. 전체 파이프라인 흐름

사용자 → PDF 내보내기 클릭
  │
  ▼
pageToPdf($page)                          [ExportFormatter.php]
  │
  ├─ PageContent($page)->render()          Markdown → HTML 변환
  │
  ├─ view('exports.page')                  Blade 템플릿 렌더링
  │   └─ format=pdf, engine=command          (CSS/레이아웃 포함)
  │
  └─ htmlToPdf($html)                     ★ 전처리 시작
      │
      ├─ 1. containHtml($html)             이미지 base64, 링크 절대경로
      │
      ├─ 2. HtmlDocument 로드              DOM 파싱
      │
      ├─ 3. replaceIframesWithLinks()      iframe → 링크 텍스트
      │
      ├─ 4. openDetailElements()           <details> 태그 열기
      │
      ├─ 5. replaceMermaidWithImage()      ★ Mermaid → PNG 변환
      │
      ├─ 6. pdfGenerator->fromHtml()       WeasyPrint 실행
      │
      └─ 7. cleanupMermaidTempFiles()      임시 PNG 삭제 (finally)

2.1 전체 파이프라인 다이어그램

flowchart TD
    A["사용자: PDF 내보내기 클릭"] --> B["pageToPdf($page)"]
    B --> C["PageContent($page)->render()<br/>Markdown → HTML 변환"]
    C --> D["view('exports.page')<br/>format=pdf, engine=command"]
    D --> E["htmlToPdf($html)"]

    subgraph htmlToPdf ["htmlToPdf — 전처리 허브"]
        direction TB
        E1["① containHtml($html)<br/>이미지 base64 · 링크 절대경로"]
        E2["② HtmlDocument 로드<br/>DOM 파싱"]
        E3["③ replaceIframesWithLinks()"]
        E4["④ openDetailElements()"]
        E5["⑤ replaceMermaidWithImage()<br/>Puppeteer+mmdc → PNG"]
        E6["⑥ pdfGenerator→fromHtml()<br/>WeasyPrint 실행"]
        E7["⑦ cleanupMermaidTempFiles()<br/>finally 블록"]
        E1 --> E2 --> E3 --> E4 --> E5 --> E6 --> E7
    end

    E --> E1

    style E5 fill:#f4845f,color:#fff
    style E6 fill:#f0ad4e,color:#fff
    style E1 fill:#5bc0de,color:#fff

2.2 replaceMermaidWithImage 내부 흐름

flowchart TD
    S["XPath 쿼리<br/>pre > code.language-mermaid"] --> LOOP

    subgraph LOOP ["foreach block"]
        direction TB
        L1["다이어그램 소스 추출<br/>html_entity_decode(textContent)"]
        L2["임시 .mmd 파일 생성<br/>/tmp/mmd_in_xxx.mmd"]
        L3["mmdc 실행<br/>node cli.js -i input -o output.png -s 2"]
        L4["@unlink 입력 파일"]
        L5{"성공?<br/>exitCode===0<br/>filesize > 0"}
        L6["DOM에 img 삽입<br/>src=file:///tmp/xxx.png"]
        L7["mermaidTempFiles에<br/>경로 추가"]
        L8["@unlink 출력 파일"]

        L1 --> L2 --> L3 --> L4 --> L5
        L5 -- "성공" --> L6 --> L7
        L5 -- "실패" --> L8
    end

    style L3 fill:#f4845f,color:#fff
    style L6 fill:#5bc0de,color:#fff

2.3 htmlLabels 설정별 렌더링 경로 비교

flowchart TD
    SRC["Mermaid 소스코드"] --> PATH_OLD
    SRC --> PATH_NEW

    subgraph PATH_OLD ["과거: SVG 직접 삽입 ✗"]
        direction TB
        O1["htmlLabels: true<br/>mmdc → SVG"]
        O2["SVG 내부 foreignObject<br/>HTML div로 라벨 렌더링"]
        O3["WeasyPrint 렌더링<br/>foreignObject 미지원"]
        O4["PDF 텍스트 누락 ✗"]
        O1 --> O2 --> O3 --> O4
    end

    subgraph PATH_NEW ["현재: PNG 이미지 삽입 ✓"]
        direction TB
        N1["htmlLabels: true<br/>mmdc → PNG (-s 2)"]
        N2["Puppeteer(Chromium)가<br/>완벽히 렌더링"]
        N3["file:// 경로로<br/>img 태그 삽입"]
        N4["PDF 정상 출력 ✓"]
        N1 --> N2 --> N3 --> N4
    end

    style O4 fill:#d9534f,color:#fff
    style N4 fill:#5cb85c,color:#fff

3. 각 단계 상세

3.1 pageToPdf — 진입점

// app/Exports/ExportFormatter.php
public function pageToPdf(Page $page): string
{
    $page->html = (new PageContent($page))->render();
    $html = view('exports.page', [
        'page'   => $page,
        'format' => 'pdf',
        'engine' => $this->pdfGenerator->getActiveEngine(),
        'locale' => user()->getLocale(),
    ])->render();

    return $this->htmlToPdf($html);
}

PageContent->render()는 마크다운 소스를 HTML로 변환한다. 이 시점에서 Mermaid 코드 블록은 아직 <pre><code class="language-mermaid"> 형태로 남아 있다. JavaScript 실행 없이 순수 HTML 문자열이다.

3.2 htmlToPdf — 전처리 허브

이 메서드가 전처리의 핵심이다. 모든 변환이 여기서 순서대로 실행된다.

protected function htmlToPdf(string $html): string
{
    // 1단계: 이미지 base64 인코딩 + 링크 절대경로 변환
    $html = $this->containHtml($html);

    // DOM 파싱
    $doc = new HtmlDocument();
    $doc->loadCompleteHtml($html);

    // 2단계: iframe을 링크 텍스트로 교체
    $this->replaceIframesWithLinks($doc);

    // 3단계: <details> 요소를 열린 상태로
    $this->openDetailElements($doc);

    // 4단계: Mermaid → PNG (반드시 containHtml 이후에 실행)
    $this->replaceMermaidWithImage($doc);

    $cleanedHtml = $doc->getHtml();

    // 5단계: WeasyPrint로 PDF 생성 → 6단계: 임시 파일 정리
    try {
        return $this->pdfGenerator->fromHtml($cleanedHtml);
    } finally {
        $this->cleanupMermaidTempFiles();
    }
}

실행 순서가 중요한 이유: containHtml()<img> 태그의 src를 base64로 변환하는데, replaceMermaidWithImage()가 삽입하는 PNG는 file:// 경로를 사용한다. 만약 순서가 뒤바뀌면 containHtml()file:// 경로를 base64 변환하려 시도할 수 있다. 이를 방지하기 위해 containHtml()file:// 가드를 추가했다.

3.3 containHtml — 이미지/링크 전처리

protected function containHtml(string $htmlContent): string
{
    // 이미지 처리: src를 base64 data URI로 변환
    preg_match_all("/\<img.*?src\=(\'|\")(.*?)(\'|\").*?\>/i",
        $htmlContent, $imageTagsOutput);

    if (isset($imageTagsOutput[0]) && count($imageTagsOutput[0]) > 0) {
        foreach ($imageTagsOutput[0] as $index => $imgMatch) {
            $oldImgTagString = $imgMatch;
            $srcString = $imageTagsOutput[2][$index];

            // ★ file:// 경로 가드 — Mermaid PNG 임시 파일 보호
            if (str_starts_with($srcString, 'file://')) {
                continue;
            }

            $imageEncoded = $this->imageService->imageUrlToBase64($srcString);
            if ($imageEncoded === null) {
                $imageEncoded = $srcString;
            }
            $newImgTagString = str_replace(
                $srcString, $imageEncoded, $oldImgTagString);
            $htmlContent = str_replace(
                $oldImgTagString, $newImgTagString, $htmlContent);
        }
    }

    // 링크 처리: 상대 경로를 절대 URL로 변환
    // 단, #으로 시작하는 앵커 링크는 제외 (각주 등 문서 내 링크 보존)
    preg_match_all("/\<a.*href\=(\'|\")(.*?)(\'|\").*?\>/i",
        $htmlContent, $linksOutput);

    if (isset($linksOutput[0]) && count($linksOutput[0]) > 0) {
        foreach ($linksOutput[0] as $index => $linkMatch) {
            $oldLinkString = $linkMatch;
            $srcString = $linksOutput[2][$index];
            if (!str_starts_with(trim($srcString), 'http')
                && !str_starts_with(trim($srcString), '#')) {
                $newSrcString = url($srcString);
                $newLinkString = str_replace(
                    $srcString, $newSrcString, $oldLinkString);
                $htmlContent = str_replace(
                    $oldLinkString, $newLinkString, $htmlContent);
            }
        }
    }
    return $htmlContent;
}

주의 사항:

  • file:// 가드가 없으면 Mermaid PNG 경로를 imageUrlToBase64()에 넘기게 되어, 스토리지 경로 변환 실패로 null이 반환된다.
  • 앵커 링크(#fn1, #fnref1)를 절대 URL로 변환하면 PDF 내 각주 링크가 깨진다. 이 수정은 각주 기능 구현 시 추가되었다.

3.4 replaceMermaidWithImage — 핵심 변환

이 메서드가 Mermaid 코드 블록을 PNG 이미지로 교체하는 핵심 로직이다.

protected function replaceMermaidWithImage(HtmlDocument $doc): void
{
    $blocks = $doc->queryXPath(
        '//pre/code[contains(@class, "language-mermaid")]'
    );
    \Log::debug('Mermaid blocks: ' . $blocks->length);

    $nodePath       = '/usr/local/bin/node';
    $mmdcScript     = '/var/www/bookstack/node_modules/'
                    . '@mermaid-js/mermaid-cli/src/cli.js';
    $puppeteerConfig = base_path('puppeteer-config.json');

    if (!file_exists($mmdcScript)) {
        \Log::debug('mmdc script not found');
        return;
    }

    $blockArray = iterator_to_array($blocks);
    foreach ($blockArray as $block) {
        $pre  = $block->parentNode;
        $code = html_entity_decode($block->textContent, ENT_QUOTES);

        // 임시 파일 생성
        $tmpIn  = tempnam(sys_get_temp_dir(), 'mmd_in_') . '.mmd';
        $tmpOut = tempnam(sys_get_temp_dir(), 'mmd_out_') . '.png';

        file_put_contents($tmpIn, $code);

        // mmdc 실행: Puppeteer(Chromium) → PNG
        $cmd = sprintf(
            'PUPPETEER_CACHE_DIR=/var/www/bookstack/storage/puppeteer '
            . '%s %s -i %s -o %s -c %s --puppeteerConfigFile %s -s 2 2>&1',
            $nodePath,
            $mmdcScript,
            escapeshellarg($tmpIn),
            escapeshellarg($tmpOut),
            escapeshellarg(base_path('mermaid-config.json')),
            escapeshellarg($puppeteerConfig)
        );

        $output = [];
        exec($cmd, $output, $exitCode);
        \Log::debug('exit: ' . $exitCode
            . ' png exists: ' . (file_exists($tmpOut) ? 'yes' : 'no')
            . ' output: ' . implode(' ', $output));

        // 입력 파일은 즉시 삭제
        @unlink($tmpIn);

        if ($exitCode === 0 && file_exists($tmpOut)
            && filesize($tmpOut) > 0) {
            // DOM에 <img> 삽입 — file:// 경로 참조
            $img = $doc->createElement('img');
            $img->setAttribute('src', 'file://' . $tmpOut);
            $img->setAttribute('style',
                'max-width:100%; height:auto;');
            $img->setAttribute('class', 'mermaid-diagram');

            $wrapper = $doc->createElement('div');
            $wrapper->setAttribute('class',
                'mermaid-diagram-wrapper');
            $wrapper->setAttribute('style',
                'text-align:center; margin:1em 0;');
            $wrapper->appendChild($img);

            $pre->parentNode->replaceChild($wrapper, $pre);

            // WeasyPrint가 읽을 때까지 PNG 유지
            $this->mermaidTempFiles[] = $tmpOut;
            \Log::debug('PNG file referenced: ' . $tmpOut
                . ' (' . filesize($tmpOut) . ' bytes)');
        } else {
            @unlink($tmpOut);
        }
    }
}

mmdc 명령어 옵션 설명

옵션 역할
-i 입력 .mmd 파일 Mermaid 소스
-o 출력 .png 파일 PNG 형식 지정 (확장자로 결정)
-c mermaid-config.json Mermaid 설정 (theme 등)
--puppeteerConfigFile puppeteer-config.json Chromium 실행 옵션
-s 2 스케일 팩터 2배 해상도 출력

설정 파일 내용

// mermaid-config.json — htmlLabels: true(기본값) 사용
{
  "theme": "default"
}

// puppeteer-config.json
{
  "args": ["--no-sandbox", "--disable-setuid-sandbox"],
  "cacheDirectory": "/var/www/bookstack/storage/puppeteer"
}

3.5 cleanupMermaidTempFiles — 임시 파일 정리

protected function cleanupMermaidTempFiles(): void
{
    foreach ($this->mermaidTempFiles as $tmpFile) {
        if (file_exists($tmpFile)) {
            @unlink($tmpFile);
            \Log::debug('Cleaned up Mermaid temp file: ' . $tmpFile);
        }
    }
    $this->mermaidTempFiles = [];
}

try/finally로 호출되므로 PDF 생성 성공/실패와 무관하게 실행된다. 이전에 대용량 이미지로 인한 메모리 초과 경험이 있어, 임시 파일이 /tmp에 누적되는 것을 방지하기 위해 이 패턴을 사용한다.


4. 왜 PNG인가 — 의사결정 기록

4.1 시도한 접근들

시도 방식 결과 실패 원인
1차 SVG + htmlLabels: true 텍스트 누락 WeasyPrint가 SVG 내 <foreignObject> 미지원
2차 SVG + htmlLabels: false 특수문자 깨짐 Mermaid 자체 버그 — <br/>, \, &, <> 처리 불량
최종 PNG + htmlLabels: true 정상 Puppeteer가 완벽히 렌더링한 결과를 래스터 이미지로 캡처

4.2 htmlLabels 설정의 영향

htmlLabels는 Mermaid가 노드 라벨을 렌더링하는 방식을 결정한다.

htmlLabels: true (기본값) — HTML/CSS로 라벨 렌더링:

<svg>
  <foreignObject>
    <div xmlns="http://www.w3.org/1999/xhtml">
      <span>노드 텍스트</span>
    </div>
  </foreignObject>
</svg>

htmlLabels: false — 순수 SVG 텍스트 요소:

<svg>
  <text>
    <tspan>노드 텍스트</tspan>
  </text>
</svg>

PNG 방식에서는 Puppeteer(Chromium)가 렌더링을 완료한 뒤 스크린샷을 찍으므로, <foreignObject> 문제가 발생하지 않는다. 따라서 htmlLabels: true(기본값)를 사용하여 줄바꿈, 특수문자, 긴 텍스트 등을 정상 처리한다.

4.3 임시 파일 참조 vs base64 인라인

base64 인라인 임시 파일 참조 (채택)
PHP 메모리 높음 (이미지 데이터 전체 로드) 낮음 (경로 문자열만)
PDF 생성 속도 느림 (HTML 문자열 비대) 빠름
과거 문제 base64로 HTML 크기 폭증 → 변환 중 뻗음 없음
임시 파일 관리 불필요 필요 (finally로 해결)

5. 실행 환경 의존성

컴포넌트 경로 / 설정 용도
Node.js /usr/local/bin/node mmdc 실행 (nvm이 아닌 복사본 — www-data 접근 가능)
mmdc node_modules/@mermaid-js/mermaid-cli/src/cli.js Mermaid → PNG 변환
Puppeteer cache /var/www/bookstack/storage/puppeteer Chromium 바이너리 저장
chrome-headless-shell storage/puppeteer 하위 Puppeteer가 사용하는 브라우저
WeasyPrint 시스템 명령어 HTML → PDF 최종 변환
mermaid-config.json /var/www/bookstack/mermaid-config.json Mermaid 렌더링 설정
puppeteer-config.json /var/www/bookstack/puppeteer-config.json Chromium 실행 옵션

중요: nvm으로 설치한 Node.js는 www-data(PHP-FPM 사용자)가 접근할 수 없다. /usr/local/bin/node에 바이너리를 복사해야 한다.


6. 로그 확인

PDF export 시 아래 로그가 storage/logs/laravel.log에 기록된다.

정상 동작 시:

[2026-03-23] production.DEBUG: Mermaid blocks: 2
[2026-03-23] production.DEBUG: exit: 0 png exists: yes output: Generating single mermaid chart
[2026-03-23] production.DEBUG: PNG file referenced: /tmp/mmd_out_xxx.png (45231 bytes)
[2026-03-23] production.DEBUG: Cleaned up Mermaid temp file: /tmp/mmd_out_xxx.png

mmdc 실패 시:

[2026-03-23] production.DEBUG: exit: 1 png exists: no output: Error: ...

7. 브라우저 렌더링과의 관계

PDF export 파이프라인은 브라우저 렌더링과 완전히 독립적이다.

항목 브라우저 (페이지 보기) PDF 내보내기
Mermaid 로드 mermaid-init.js (Custom HTML Head) mmdc CLI
렌더링 엔진 브라우저 JS 엔진 Puppeteer (Chromium headless)
출력 형식 SVG (인라인) PNG (file:// 참조)
뷰어 mermaid-viewer.js (클릭 확대) 없음 (이미지)
최종 변환 없음 (브라우저가 직접 표시) WeasyPrint → PDF
설정 파일 mermaid-init.js 내 initialize() mermaid-config.json

브라우저 쪽 파일(mermaid-init.js, mermaid-viewer.js, mermaid-viewer.css)을 수정해도 PDF export에는 영향이 없고, 그 반대도 마찬가지다.


8. 수정 이력

날짜 파일 변경 내용
2026-02-23 ExportFormatter.php replaceMermaidWithSvg 메서드 신규 추가 (SVG 방식)
2026-03-11 ExportFormatter.php SVG → PNG 전환, htmlToPdf에 try/finally 래핑
2026-03-11 mermaid-config.json htmlLabels: false 제거, 기본값 사용
2026-03-23 ExportFormatter.php 메서드명 replaceMermaidWithSvgreplaceMermaidWithImage
2026-03-23 ExportFormatter.php containHtml()file:// 경로 가드 추가

9. 관련 문서

  • Mermaid Part 1 ~ Part 4: 브라우저 렌더링, 에디터 프리뷰, Interactive Viewer
  • Mermaid Part 5 — PDF Export PNG 전환: 이 문서의 의사결정 배경 상세
  • 각주 PDF Export: containHtml() 앵커 링크 수정
  • 이미지 메모리 제한: ImageService.php 50MB 제한 패치