(続々々)SVG帳票

昨年末より何回か続いたSVG帳票シリーズ（前回[続々SVG帳票](https://qiita.com/mackey1966/items/fade7bb1b37f2b0d2ae1)）だが大事なことを後回しにしていた。画像問題である。きっかけになった以下の記事で軽く触れられているがBase64データをプレースホルダーとして置換するというちょっとアレな方法。

https://zenn.dev/ttskch/articles/1f1572cfd2e375

SVGの画像は埋め込みが基本でリンクは非推奨だ。実際PDF変換ツールをいくつか試してみたがリンクでは変換できないし、埋め込みも容量が大きくなると失敗する。これじゃ画像を扱えないじゃないか。

そこで考えた。SVGから一気にPDFにしようと思うからできないのであって、画像なしのPDFとリンク画像のPDFを用意してマージすればよいのではないか。試行錯誤の末（今回もClaude 3に大変お世話になったが）できた。

FigmaでエクスポートしたSVGの画像部分は以下の感じになっているのでimage要素のxlink:hrefを手動でプレースホルダーに書き換えておく（この例では{image1_placeholder}にしてます）。

```xml
<svg width="595" height="420" viewBox="0 0 595 420" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<rect width="595" height="420" fill="#F5F5F5"/>
<g id="certificate">
<rect id="image 1" x="440" y="55" width="100" height="100" fill="url(#pattern0_0_1)"/>
<rect id="image 2" x="498" y="284" width="60" height="60" fill="url(#pattern1_0_1)"/>
</g>
<defs>
<pattern id="pattern0_0_1" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0_0_1" transform="scale(0.0078125)"/>
</pattern>
<pattern id="pattern1_0_1" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image1_0_1" transform="scale(0.0166667)"/>
</pattern>
<image id="image0_0_1" data-name="french-bulldog-silhouette_gray_x128.png" width="128" height="128" xlink:href="{image1_placeholder}"/>
<image id="image1_0_1" data-name="Logo_.png" width="60" height="60" xlink:href="{image2_placeholder}"/>
</defs>
```
これをVueで実際の画像URLに置換してAPIサーバーに投げてPDFを返してもらう。サーバー側は以下の感じ。

```py
UPLOAD_DIR = "./files"

def get_pdf(db: Session, request: schemas.File):
    try:
        pdf_files = []
        for i, svg in enumerate(reversed(request.file)):
            svg_file = os.path.join(UPLOAD_DIR, str(i).zfill(3) + "-" + request.name + ".svg")
            with open(svg_file, 'w') as f:
                f.write(svg)

            # 背景PDFを作成
            background_pdf_file = os.path.join(UPLOAD_DIR, str(i).zfill(3) + "-" + request.name + "_background.pdf")
            subprocess.run("rsvg-convert -f pdf -o " + background_pdf_file + " " + svg_file, shell=True)

            # 画像のリンク情報を抽出
            image_links = extract_image_links(svg_file)

            # 画像をPDFに変換
            image_pdf_file = os.path.join(UPLOAD_DIR, str(i).zfill(3) + "-" + request.name + "_images.pdf")
            convert_images_to_pdf(svg_file, image_links, image_pdf_file, request.name)

            # レイヤーを結合
            merged_pdf_file = os.path.join(UPLOAD_DIR, str(i).zfill(3) + "-" + request.name + "_merged.pdf")
            merge_pdfs_as_layers(background_pdf_file, image_pdf_file, merged_pdf_file)
            pdf_files.append(merged_pdf_file)

        # 複数のPDFを1つにマージ
        final_pdf_file = os.path.join(UPLOAD_DIR, request.name + ".pdf")
        merge_pdfs(pdf_files, final_pdf_file)

        # 一時ファイルを削除
        subprocess.run(f"rm -f {os.path.join(UPLOAD_DIR, '*' + request.name + '*.svg')}", shell=True)
        subprocess.run(f"rm -f {os.path.join(UPLOAD_DIR, '*' + request.name + '_*')}", shell=True)

        filename = request.name + ".pdf"
        response = FileResponse(
            path=final_pdf_file,
            filename=filename
        )
        return response
    except Exception as e:
        logging.error(f"get_pdf error: {str(e)}")
        raise e
    
def extract_image_links(svg_file):
    tree = ET.parse(svg_file)
    root = tree.getroot()
    image_links = []
    
    for elem in root.iter("{http://www.w3.org/2000/svg}image"):
        image_link = elem.get("{http://www.w3.org/1999/xlink}href")
        image_id = elem.get("id")
        if image_link:
            image_id = image_id.replace("image", "")  # idからimage部分を取り除く（0_0_1部分を取得）
            image_links.append((image_id, image_link))
    
    logging.debug(f"Extracted image links from {svg_file}: {image_links}")
    return image_links

def convert_images_to_pdf(svg_file, image_links, output_file, file_prefix):
    try:
        # SVGファイルを解析する
        tree = ET.parse(svg_file)
        root = tree.getroot()

        # 用紙サイズを取得する
        width = float(root.attrib['width'])
        height = float(root.attrib['height'])

        # 新しいPDFを作成する
        c = canvas.Canvas(output_file, pagesize=(width, height))

        for i, (image_id, link) in enumerate(image_links):
            # Parse the URL
            parsed_url = urllib.parse.urlparse(link)
            
            # Extract the file name from the parsed URL path
            file_name = os.path.basename(parsed_url.path)
            
            # Decode the URL-encoded file name
            decoded_file_name = urllib.parse.unquote(file_name)
            
            # Extract the file extension from the decoded file name
            file_extension = os.path.splitext(decoded_file_name)[1]
            
            # Generate a new file name using the file prefix, index, and extension
            new_file_name = f"{file_prefix}_image{i}{file_extension}"
            
            # Construct the file path
            file_path = os.path.join(UPLOAD_DIR, new_file_name)
            
            # Download the image and save it locally
            response = requests.get(link)
            with open(file_path, "wb") as f:
                f.write(response.content)
            
            # 画像を読み込む
            image = Image.open(file_path)

            logging.debug(f"Downloaded image: {file_path}")
            logging.debug(f"Image size: {image.size}")         

            # 画像を表示すべき位置とサイズを取得
            rect_elem = None
            for elem in root.findall(".//svg:rect", namespaces={'svg': 'http://www.w3.org/2000/svg'}):
                fill_attr = elem.get("fill")
                if fill_attr:
                    fill_id = fill_attr.replace("url(#pattern", "").replace(")", "")
                    if fill_id == image_id:
                        rect_elem = elem
                        break
            if rect_elem is not None:
                x = float(rect_elem.attrib.get('x', '0'))
                y = float(rect_elem.attrib.get('y', '0'))
                rect_width = float(rect_elem.attrib.get('width', '0'))
                rect_height = float(rect_elem.attrib.get('height', '0'))
                logging.debug(f"Rect element: {x, y, rect_width, rect_height}")

                # 画像のリサイズと配置の処理
                # 一時画像ファイルの縦横のサイズから縦横比を計算する
                img_width, img_height = image.size
                aspect_ratio = img_width / img_height

                # 画像のサイズを計算する
                if aspect_ratio > rect_width / rect_height:
                    # 横幅に合わせる
                    new_width = rect_width
                    new_height = new_width / aspect_ratio
                else:
                    # 高さに合わせる
                    new_height = rect_height
                    new_width = new_height * aspect_ratio

                # 中央の位置を計算する
                center_x = x + rect_width / 2
                center_y = height - (y + rect_height / 2)

                # 画像をリサイズする
                resized_image = image.resize((int(new_width), int(new_height)))

                logging.debug(f"Resized image size: {resized_image.size}")
                logging.debug(f"Placed image at ({center_x - new_width / 2}, {center_y - new_height / 2})")

                # リサイズした画像をPDFに配置する
                c.drawImage(ImageReader(resized_image), center_x - new_width / 2, center_y - new_height / 2)

            else:
                logging.debug(f"Could not find rect element with fill containing 'url(#{pattern_id})'")

                # SVG内の全てのrect要素を取得してデバッグ情報として出力
                rect_elems = root.findall(".//svg:rect", namespaces={'svg': 'http://www.w3.org/2000/svg'})
                logging.debug(f"All rect elements in the SVG:")
                for rect in rect_elems:
                    logging.debug(f"  - {rect.attrib}")

        # PDFを保存する
        c.showPage()
        c.save()
    except Exception as e:
        logging.error(f"convert_images_to_pdf error: {str(e)}")
        raise e

def merge_pdfs_as_layers(background_pdf, foreground_pdf, output_pdf):
    try:
        # 背景PDFを読み込む
        background = PdfReader(open(background_pdf, 'rb'))
        
        # 前景PDFを読み込む
        foreground = PdfReader(open(foreground_pdf, 'rb'))
        
        # 出力用のPDFWriterを作成
        output = PdfWriter()
        
        # 背景PDFの最初のページを取得
        page = background.pages[0]
        
        # 前景PDFの最初のページを取得
        overlay_page = foreground.pages[0]
        
        # 背景ページの上に前景ページを重ねる
        page.merge_page(overlay_page)
        
        # 結合したページを出力PDFに追加
        output.add_page(page)
        
        # 出力PDFを保存
        with open(output_pdf, 'wb') as f:
            output.write(f)
    except Exception as e:
        logging.error(f"merge_pdfs_as_layers error: {str(e)}")
        raise e
    
def merge_pdfs(pdf_files, output_pdf):
    merger = PyPDF2.PdfMerger()

    for pdf in reversed(pdf_files):
        with open(pdf, 'rb') as file:
            reader = PyPDF2.PdfReader(file)
            merger.append(reader)

    with open(output_pdf, 'wb') as file:
        merger.write(file)
```
これで画像問題は解決だ。