xmlビューワ

Python

個人ビジネスをやっていると、保険などいろいろなお知らせが、電子申請サービスから送られてきます。

zipで複数のファイルがひとまとめにされていて、メインはxmlファイルなのだが、これを見るのがいろいろと面倒なのです。
という訳で、e-Govファイルビューワを作ってみました。

特徴
・eGovからdownloadしたzipファイルのまま、閲覧・PDF化・印刷が可能
・複数xmlを含む場合は選択して閲覧・PDF化・印刷が可能

簡易的なものなので、ごくたまにレイアウトが崩れたりします。
また、私が見たことのない文書は、大きく崩れる可能性があります。


中で悪いことをやっていないことをご確認ください

Python
"""
XML/XSLT Viewer & PDF Converter
ZIP input support with Windows file-open compatibility.
©2026 Sketlab LLC.
"""

import os
import re
import shutil
import sys
import tempfile
import zipfile
from pathlib import Path

# 画面が真っ白・真っ黒になるのを防ぐ
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu --disable-gpu-compositing --no-sandbox"

from lxml import etree

from PySide6.QtCore import QMarginsF, QRect, QSize, Qt, QTimer, QUrl
from PySide6.QtGui import QFont, QIcon, QPageLayout, QPageSize, QPainter
from PySide6.QtPdf import QPdfDocument
from PySide6.QtPrintSupport import QPrintDialog, QPrinter
from PySide6.QtWebEngineWidgets import QWebEngineView
from PySide6.QtWidgets import (
    QApplication,
    QDialog,
    QDialogButtonBox,
    QFileDialog,
    QFrame,
    QHBoxLayout,
    QLabel,
    QListWidget,
    QMainWindow,
    QMessageBox,
    QPushButton,
    QSizePolicy,
    QTextEdit,
    QVBoxLayout,
    QWidget,
)


def get_folder_path():
    """プログラムが動いているフォルダの場所を返す"""
    if getattr(sys, "frozen", False):
        return os.path.dirname(sys.executable)
    return os.path.dirname(os.path.abspath(__file__))


def get_icon_file():
    """アイコンのファイルの場所を探す"""
    base_folder = get_folder_path()
    check_paths = [
        os.path.join(base_folder, "assets", "xml_viewer_icon.ico"),
        os.path.join(base_folder, "xml_viewer_icon.ico"),
    ]
    for file_path in check_paths:
        if os.path.exists(file_path):
            return file_path
    return None


class FileSentakuDialog(QDialog):
    """ZIPの中にXMLが何個もあるときに選ぶ画面"""

    def __init__(self, xml_list, parent=None):
        super().__init__(parent)
        self.setWindowTitle("XMLファイル選択")
        self.resize(700, 400)
        self.erandata_file = None

        layout = QVBoxLayout(self)

        setsumei_label = QLabel("ZIPファイルの中にXMLファイルが複数あります。どれを開くか選んでください。")
        layout.addWidget(setsumei_label)

        self.list_widget = QListWidget()
        self.list_widget.addItems(xml_list)
        self.list_widget.itemDoubleClicked.connect(self.ok_oshita)
        layout.addWidget(self.list_widget)

        buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
        buttons.accepted.connect(self.ok_oshita)
        buttons.rejected.connect(self.reject)
        layout.addWidget(buttons)

    def ok_oshita(self):
        item = self.list_widget.currentItem()
        if item:
            self.erandata_file = item.text()
            self.accept()
            return
        QMessageBox.warning(self, "未選択", "XMLファイルを選んでください。")


class MainApp(QMainWindow):
    """メインの画面"""

    def __init__(self):
        super().__init__()

        # タイトルバーに著作権表記を追加
        self.setWindowTitle("XML/XSLT Viewer & PDF Converter - ©2026 Sketlab LLC.")
        self.resize(1000, 900)
        self.gamen_no_mannaka_ni_ido()
        self.setAcceptDrops(True)

        self.xml_path = None
        self.xsl_path = None
        self.pdf_hozon_path = None
        self.temp_folder = None
        self.zip_path = None
        
        self.page_size_id = QPageSize.PageSizeId.A4
        self.page_muki = QPageLayout.Orientation.Portrait
        self.page_haba_mm = 210
        self.page_tasa_mm = 297
        self.page_bairitsu = 1.0
        self.content_haba_px = 0
        self.content_tasa_px = 0
        self.scaled_haba_px = 0
        self.scaled_tasa_px = 0
        self.use_scaled_layout = False
        self.page_yohaku_mm = 5

        main_widget = QWidget()
        self.setCentralWidget(main_widget)
        main_layout = QVBoxLayout(main_widget)
        main_layout.setContentsMargins(10, 10, 10, 10)
        main_layout.setSpacing(5)

        ue_bar = QHBoxLayout()

        self.open_button = QPushButton("開く")
        self.open_button.setFixedSize(80, 30)
        self.open_button.clicked.connect(self.file_open_dialog)
        ue_bar.addWidget(self.open_button)

        self.hozon_button = QPushButton("PDFに保存")
        self.hozon_button.setFixedSize(120, 30)
        self.hozon_button.clicked.connect(self.pdf_hozon_suru)
        self.hozon_button.setEnabled(False)
        ue_bar.addWidget(self.hozon_button)

        self.print_button = QPushButton("印刷")
        self.print_button.setFixedSize(80, 30)
        self.print_button.clicked.connect(self.insatsu_suru)
        self.print_button.setEnabled(False)
        ue_bar.addWidget(self.print_button)

        ue_bar.addStretch()

        self.license_button = QPushButton("ライセンス")
        self.license_button.setCursor(Qt.PointingHandCursor)
        self.license_button.setStyleSheet(
            """
            QPushButton {
                border: none;
                color: #555;
                background: transparent;
                text-decoration: underline;
                font-size: 12px;
            }
            QPushButton:hover { color: blue; }
        """
        )
        self.license_button.clicked.connect(self.show_license_gamen)
        ue_bar.addWidget(self.license_button)
        main_layout.addLayout(ue_bar)

        info_box = QWidget()
        info_box.setFixedHeight(80)
        info_layout = QVBoxLayout(info_box)
        info_layout.setContentsMargins(0, 5, 0, 5)
        info_layout.setSpacing(5)

        row1 = QHBoxLayout()
        xml_title_label = QLabel("XML FILE")
        xml_title_label.setFont(QFont("Arial", 9, QFont.Bold))
        row1.addWidget(xml_title_label)

        self.xml_label = QLabel("")
        self.xml_label.setFixedHeight(30)
        self.xml_label.setStyleSheet("border: 1px solid #333; padding-left: 5px; background: white; color: #333;")
        self.xml_label.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
        row1.addWidget(self.xml_label, stretch=1)

        row1.addSpacing(20)

        xsl_title_label = QLabel("XSL FILE")
        xsl_title_label.setFont(QFont("Arial", 9, QFont.Bold))
        row1.addWidget(xsl_title_label)

        self.xsl_label = QLabel("")
        self.xsl_label.setFixedHeight(30)
        self.xsl_label.setStyleSheet("border: 1px solid #333; padding-left: 5px; background: white; color: #333;")
        self.xsl_label.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
        row1.addWidget(self.xsl_label, stretch=1)
        info_layout.addLayout(row1)

        row2 = QHBoxLayout()
        zip_title_label = QLabel("ZIP FILE")
        zip_title_label.setFont(QFont("Arial", 9, QFont.Bold))
        row2.addWidget(zip_title_label)

        self.zip_label = QLabel("")
        self.zip_label.setFixedHeight(30)
        self.zip_label.setStyleSheet("border: 1px solid #333; padding-left: 5px; background: white; color: #333;")
        self.zip_label.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
        row2.addWidget(self.zip_label, stretch=1)
        info_layout.addLayout(row2)

        main_layout.addWidget(info_box)

        sen = QFrame()
        sen.setFrameShape(QFrame.HLine)
        sen.setFrameShadow(QFrame.Plain)
        sen.setStyleSheet("color: #005580; background-color: #005580; min-height: 2px;")
        main_layout.addWidget(sen)

        self.web_gamen = QWebEngineView()
        self.web_gamen.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
        self.web_gamen.setStyleSheet("background-color: #525659;")
        main_layout.addWidget(self.web_gamen)

        QTimer.singleShot(100, self.hikisuu_check)

    def closeEvent(self, event):
        self.kesu_temp_folder()
        super().closeEvent(event)

    def kesu_temp_folder(self):
        if self.temp_folder and os.path.exists(self.temp_folder):
            try:
                shutil.rmtree(self.temp_folder)
            except Exception:
                pass
        self.temp_folder = None

    def gamen_no_mannaka_ni_ido(self):
        try:
            screen_size = self.screen().availableGeometry()
            x = (screen_size.width() - self.width()) // 2 + screen_size.x()
            y = (screen_size.height() - self.height()) // 2 + screen_size.y()
            self.move(x, y)
        except Exception:
            pass

    def show_license_gamen(self):
        license_files = ["LICENSE", "LICENSE.txt", "license.txt"]
        content = "©2026 Sketlab LLC.\n\nLicense file not found."
        base_folder = os.path.dirname(os.path.abspath(sys.argv[0]))
        for file_name in license_files:
            file_path = os.path.join(base_folder, file_name)
            if not os.path.exists(file_path):
                continue
            try:
                with open(file_path, "r", encoding="utf-8") as f:
                    content = f.read()
                break
            except Exception as error:
                content = f"Error reading license file:\n{error}"

        dialog = QDialog(self)
        dialog.setWindowTitle("License")
        dialog.resize(600, 400)
        layout = QVBoxLayout(dialog)

        text_edit = QTextEdit()
        text_edit.setReadOnly(True)
        text_edit.setPlainText(content)
        text_edit.setFont(QFont("Consolas", 10))
        layout.addWidget(text_edit)

        close_button = QPushButton("閉じる")
        close_button.clicked.connect(dialog.close)
        layout.addWidget(close_button)
        dialog.exec()

    def hikisuu_check(self):
        args = sys.argv[1:]
        if not args:
            return

        input_path = args[0]
        if os.path.isfile(input_path):
            self.file_wo_hiraite_shori(input_path)

        if len(args) > 1:
            arg2 = args[1]
            if arg2.lower().endswith(".xsl"):
                self.xsl_yomikomi(arg2)
                if len(args) > 2:
                    self.pdf_hozon_path = args[2]
            else:
                self.pdf_hozon_path = arg2

    def file_open_dialog(self):
        path, _ = QFileDialog.getOpenFileName(
            self,
            "XML/ZIPファイルを開く",
            "",
            "XML or ZIP Files (*.xml *.zip);;XML Files (*.xml);;ZIP Files (*.zip);;All Files (*.*)",
        )
        if path:
            self.file_wo_hiraite_shori(path)

    def file_wo_hiraite_shori(self, path):
        kakuchago = os.path.splitext(path)[1].lower()
        if kakuchago == ".zip":
            self.zip_yomikomi(path)
            return
        if kakuchago == ".xml":
            self.zip_path = None
            self.zip_label.setText("")
            self.zip_label.setToolTip("")
            self.xml_yomikomi(path)
            return
        if kakuchago == ".xsl":
            self.xsl_yomikomi(path)
            return
        QMessageBox.warning(self, "非対応", "XML / ZIP / XSL ファイルを指定してください。")

    def dragEnterEvent(self, event):
        if event.mimeData().hasUrls():
            event.accept()
            return
        event.ignore()

    def dropEvent(self, event):
        files = [url.toLocalFile() for url in event.mimeData().urls()]
        if not files:
            return

        first_file = files[0]
        self.file_wo_hiraite_shori(first_file)

        for path in files[1:]:
            if path.lower().endswith(".xsl"):
                self.xsl_yomikomi(path)
                break

    def zip_yomikomi(self, zip_file_path):
        try:
            self.kesu_temp_folder()
            self.temp_folder = tempfile.mkdtemp(prefix="egov_xml_")
            self.zip_path = zip_file_path

            with zipfile.ZipFile(zip_file_path, "r") as zip_file:
                self.zip_unzip_anzen(zip_file, self.temp_folder)

            self.zip_label.setText(os.path.basename(zip_file_path))
            self.zip_label.setToolTip(zip_file_path)

            xml_files = self.folder_no_naka_sagasu(self.temp_folder, [".xml"])
            if not xml_files:
                QMessageBox.warning(self, "エラー", "ZIPファイルの中にXMLファイルがありませんでした。")
                return

            xml_files = self.xml_narabetikae(xml_files)
            rel_xml_files = [os.path.relpath(path, self.temp_folder) for path in xml_files]

            if len(xml_files) == 1:
                selected_xml = xml_files[0]
            else:
                dialog = FileSentakuDialog(rel_xml_files, self)
                if dialog.exec() != QDialog.Accepted or not dialog.erandata_file:
                    return
                selected_xml = os.path.join(self.temp_folder, dialog.erandata_file)

            self.xml_yomikomi(selected_xml)
        except Exception as error:
            QMessageBox.critical(self, "エラー", f"ZIPファイルの読み込みに失敗しました:\n{error}")

    def zip_unzip_anzen(self, zip_file, nani_folder):
        base_dir = Path(nani_folder).resolve()
        for member in zip_file.infolist():
            member_name = member.filename
            if not member_name or member_name.endswith("/"):
                continue

            target_path = (base_dir / member_name).resolve()
            if os.path.commonpath([str(base_dir), str(target_path)]) != str(base_dir):
                raise ValueError(f"ZIP内に不正なパスが含まれています: {member_name}")

            target_path.parent.mkdir(parents=True, exist_ok=True)
            with zip_file.open(member, "r") as source, open(target_path, "wb") as target:
                shutil.copyfileobj(source, target)

    def xml_narabetikae(self, xml_files):
        def score_keisan(path):
            name = os.path.basename(path).lower()
            stem = os.path.splitext(name)[0]
            total = 0

            if re.fullmatch(r"\d{8,}", stem):
                total += 50
            if "yoshiki" in name:
                total += 10
            if "kagami" in name:
                total -= 20
            if "manifest" in name:
                total -= 10

            try:
                total += min(os.path.getsize(path) // 1024, 100)
            except Exception:
                pass
            return total

        return sorted(xml_files, key=score_keisan, reverse=True)

    def folder_no_naka_sagasu(self, root_folder, kakuchago_list):
        found_files = []
        target_kakuchago = {k.lower() for k in kakuchago_list}
        for root, _, files in os.walk(root_folder):
            for file_name in files:
                _, ext = os.path.splitext(file_name)
                if ext.lower() in target_kakuchago:
                    found_files.append(os.path.join(root, file_name))
        return found_files

    def xml_yomikomi(self, path):
        self.xml_path = path
        self.xml_label.setText(os.path.basename(path))
        self.xml_label.setToolTip(path)

        self.xsl_path = None
        self.xsl_label.setText("(検索中...)")
        self.xsl_label.setToolTip("")

        found_xsl = self.xml_ni_au_xsl_sagasu(path)
        if found_xsl and os.path.exists(found_xsl):
            self.xsl_yomikomi(found_xsl)
            return

        self.xsl_label.setText("(見つかりません)")
        self.web_gamen.setHtml("")
        self.hozon_button.setEnabled(False)
        self.print_button.setEnabled(False)
        QMessageBox.warning(
            self,
            "XSL未検出",
            "XMLに対応するXSLファイルが見つかりませんでした。\n必要であればXSLファイルを別途ドラッグ&ドロップしてください。",
        )

    def xml_ni_au_xsl_sagasu(self, xml_file_path):
        found = self.xml_no_naka_kara_xsl_sagasu(xml_file_path)
        if found and os.path.exists(found):
            return found

        base_xsl = os.path.splitext(xml_file_path)[0] + ".xsl"
        if os.path.exists(base_xsl):
            return base_xsl

        xml_dir = os.path.dirname(xml_file_path)
        local_xsls = self.folder_no_naka_sagasu(xml_dir, [".xsl"])
        if len(local_xsls) == 1:
            return local_xsls[0]
        if len(local_xsls) > 1:
            best = self.ichiban_ii_xsl_erabu(xml_file_path, local_xsls)
            if best:
                return best

        if self.temp_folder and os.path.exists(self.temp_folder):
            all_xsls = self.folder_no_naka_sagasu(self.temp_folder, [".xsl"])
            if len(all_xsls) == 1:
                return all_xsls[0]
            if len(all_xsls) > 1:
                best = self.ichiban_ii_xsl_erabu(xml_file_path, all_xsls)
                if best:
                    return best

        return None

    def xml_no_naka_kara_xsl_sagasu(self, xml_file_path):
        try:
            with open(xml_file_path, "rb") as f:
                head = f.read(4096)
            match = re.search(br'<\?xml-stylesheet[^>]*href=["\']([^"\']+)["\']', head, flags=re.IGNORECASE)
            if match:
                xsl_name = match.group(1).decode("utf-8", errors="ignore")
                candidate = os.path.normpath(os.path.join(os.path.dirname(xml_file_path), xsl_name))
                if os.path.exists(candidate):
                    return candidate
        except Exception as error:
            print(f"PI read error: {error}")

        try:
            tree = etree.parse(xml_file_path)
            pis = tree.xpath("//processing-instruction('xml-stylesheet')")
            for pi in pis:
                match = re.search(r'href=["\'](.*?)["\']', pi.text)
                if not match:
                    continue
                xsl_name = match.group(1)
                candidate = os.path.normpath(os.path.join(os.path.dirname(xml_file_path), xsl_name))
                if os.path.exists(candidate):
                    return candidate
        except Exception as error:
            print(f"Error parsing XML for PI: {error}")

        return None

    def ichiban_ii_xsl_erabu(self, xml_file_path, xsl_list):
        xml_name = os.path.splitext(os.path.basename(xml_file_path))[0].lower()

        def score_keisan(xsl_path):
            name = os.path.basename(xsl_path).lower()
            total = 0
            if xml_name in name:
                total += 50
            if "kagami" in name:
                total += 20
            if "yoshiki" in name:
                total += 20
            return total

        sorted_list = sorted(xsl_list, key=score_keisan, reverse=True)
        return sorted_list[0] if sorted_list else None

    def xsl_yomikomi(self, path):
        self.xsl_path = path
        self.xsl_label.setText(os.path.basename(path))
        self.xsl_label.setToolTip(path)
        self.html_ni_henkan_suru()

    def check_html_size(self, html_str):
        width_values = [int(val) for val in re.findall(r'width\s*[:=]\s*["\']?(\d+)\s*px', html_str, flags=re.IGNORECASE)]
        height_values = [int(val) for val in re.findall(r'height\s*[:=]\s*["\']?(\d+)\s*px', html_str, flags=re.IGNORECASE)]

        max_width_px = max(width_values, default=640)
        max_height_px = max(height_values, default=940)
        use_scaled_layout = bool(width_values or height_values)

        if use_scaled_layout:
            max_width_px += 24
            max_height_px += 12

        portrait_inner_width_px = (210 - 16) * 96 / 25.4
        portrait_inner_height_px = (297 - 16) * 96 / 25.4
        landscape_inner_width_px = (297 - 16) * 96 / 25.4
        landscape_inner_height_px = (210 - 16) * 96 / 25.4

        portrait_scale = min(
            portrait_inner_width_px / max_width_px,
            portrait_inner_height_px / max_height_px,
        )
        landscape_scale = min(
            landscape_inner_width_px / max_width_px,
            landscape_inner_height_px / max_height_px,
        )

        safety_scale = 0.992

        if landscape_scale > portrait_scale:
            orientation = QPageLayout.Orientation.Landscape
            page_width_mm = 297
            page_height_mm = 210
            scale = landscape_scale
        else:
            orientation = QPageLayout.Orientation.Portrait
            page_width_mm = 210
            page_height_mm = 297
            scale = portrait_scale

        scale = min(1.0, scale * safety_scale) if scale > 0 else 1.0
        if not use_scaled_layout:
            scale = 1.0
            scaled_width_px = max_width_px
            scaled_height_px = max_height_px
        else:
            scaled_width_px = max(1, int(max_width_px * scale))
            scaled_height_px = max(1, int(max_height_px * scale))

        page_margin_mm = 0 if use_scaled_layout else 5

        return (
            QPageSize.PageSizeId.A4,
            orientation,
            page_width_mm,
            page_height_mm,
            scale,
            max_width_px,
            max_height_px,
            scaled_width_px,
            scaled_height_px,
            use_scaled_layout,
            page_margin_mm,
        )

    def page_layout_tsukuru(self):
        return QPageLayout(
            QPageSize(self.page_size_id),
            self.page_muki,
            QMarginsF(self.page_yohaku_mm, self.page_yohaku_mm, self.page_yohaku_mm, self.page_yohaku_mm),
            QPageLayout.Unit.Millimeter,
        )

    def html_ni_henkan_suru(self):
        if not self.xml_path or not self.xsl_path:
            return

        try:
            xml_doc = etree.parse(self.xml_path)
            xsl_doc = etree.parse(self.xsl_path)
            transform = etree.XSLT(xsl_doc)
            result_tree = transform(xml_doc)
            html_str = str(result_tree)
            (
                self.page_size_id,
                self.page_muki,
                self.page_haba_mm,
                self.page_tasa_mm,
                self.page_bairitsu,
                self.content_haba_px,
                self.content_tasa_px,
                self.scaled_haba_px,
                self.scaled_tasa_px,
                self.use_scaled_layout,
                self.page_yohaku_mm,
            ) = self.check_html_size(html_str)

            a4_style = """
            <style>
                html {
                    background-color: #525659;
                    height: 100%;
                }
                body {
                    width: __PAGE_WIDTH__mm;
                    min-height: __PAGE_HEIGHT__mm;
                    margin: 20px auto;
                    padding: __SCREEN_PADDING__;
                    background-color: white;
                    box-shadow: 0 0 10px rgba(0,0,0,0.5);
                    box-sizing: border-box;
                    font-family: "Yu Gothic", "Meiryo", sans-serif;
                    overflow: __BODY_OVERFLOW__;
                }
                p, div, span, font, td, th, li, dt, dd, a, nobr {
                    word-break: normal !important;
                    overflow-wrap: break-word !important;
                }
                pre {
                    white-space: pre-wrap !important;
                    word-break: break-all !important;
                    overflow-wrap: break-word !important;
                    font-family: inherit !important;
                }
                img {
                    max-width: 100% !important;
                    height: auto !important;
                }
                table.outline {
                    border: none !important;
                }
                .page-scale-host {
                    width: __HOST_WIDTH_PX__px;
                    height: __HOST_HEIGHT_PX__px;
                    overflow: hidden;
                    margin: 0 auto;
                }
                .page-scale-inner {
                    width: __CONTENT_WIDTH_PX__px;
                    transform: scale(__PAGE_SCALE__);
                    transform-origin: top left;
                }
                @media print {
                    @page {
                        size: __PRINT_PAGE_SIZE__;
                        margin: 0;
                    }
                    html {
                        background: white !important;
                        height: auto;
                    }
                    body {
                        width: __PAGE_WIDTH__mm;
                        margin: 0;
                        padding: __PRINT_PADDING__;
                        min-height: 0;
                        box-shadow: none;
                        overflow: __BODY_OVERFLOW__;
                        background: white;
                    }
                    .page-scale-host {
                        width: __PRINT_HOST_WIDTH_PX__px;
                        height: __PRINT_HOST_HEIGHT_PX__px;
                        overflow: hidden;
                        margin: 0 auto;
                    }
                    .page-scale-inner {
                        transform: scale(__PAGE_SCALE__);
                        transform-origin: top left;
                    }
                    table { page-break-inside: auto; }
                    tr { page-break-inside: avoid; page-break-after: auto; }
                }
            </style>
            """
            a4_style = a4_style.replace("__PAGE_WIDTH__", str(self.page_haba_mm))
            a4_style = a4_style.replace("__PAGE_HEIGHT__", str(self.page_tasa_mm))
            a4_style = a4_style.replace("__SCREEN_PADDING__", "8mm" if self.use_scaled_layout else "12mm")
            a4_style = a4_style.replace("__PRINT_PADDING__", "0" if self.use_scaled_layout else "8mm")
            a4_style = a4_style.replace("__BODY_OVERFLOW__", "hidden" if self.use_scaled_layout else "visible")
            a4_style = a4_style.replace("__PAGE_SCALE__", f"{self.page_bairitsu:.6f}")
            a4_style = a4_style.replace("__CONTENT_WIDTH_PX__", str(self.content_haba_px))
            a4_style = a4_style.replace("__HOST_WIDTH_PX__", str(self.scaled_haba_px + 4))
            a4_style = a4_style.replace("__HOST_HEIGHT_PX__", str(self.scaled_tasa_px + 4))
            a4_style = a4_style.replace("__SCALED_WIDTH_PX__", str(self.scaled_haba_px))
            a4_style = a4_style.replace("__SCALED_HEIGHT_PX__", str(self.scaled_tasa_px))
            a4_style = a4_style.replace("__PRINT_HOST_WIDTH_PX__", str(self.scaled_haba_px + 8))
            a4_style = a4_style.replace("__PRINT_HOST_HEIGHT_PX__", str(self.scaled_tasa_px + 8))
            a4_style = a4_style.replace(
                "__PRINT_PAGE_SIZE__",
                "A4 landscape" if self.page_muki == QPageLayout.Orientation.Landscape else "A4 portrait",
            )

            if "<head>" in html_str:
                html_str = html_str.replace("<head>", "<head>" + a4_style)
            elif "<html>" in html_str:
                html_str = html_str.replace("<html>", "<html>" + a4_style)
            else:
                html_str = a4_style + html_str

            body_match = re.search(r"<body([^>]*)>", html_str, flags=re.IGNORECASE)
            if body_match and self.use_scaled_layout:
                body_open = body_match.group(0)
                html_str = html_str.replace(body_open, body_open + '<div class="page-scale-host"><div class="page-scale-inner">', 1)
                html_str = re.sub(r"</body>", "</div></div></body>", html_str, count=1, flags=re.IGNORECASE)

            base_folder = os.path.dirname(self.xsl_path if self.xsl_path else self.xml_path)
            self.web_gamen.setHtml(html_str, QUrl.fromLocalFile(base_folder + os.sep))

            self.hozon_button.setEnabled(True)
            self.print_button.setEnabled(True)
        except Exception as error:
            self.web_gamen.setHtml("")
            self.hozon_button.setEnabled(False)
            self.print_button.setEnabled(False)
            QMessageBox.critical(self, "エラー", f"変換に失敗しました:\n{error}")

    def pdf_hozon_suru(self):
        default_name = ""
        if self.pdf_hozon_path:
            default_name = self.pdf_hozon_path
        elif self.xml_path:
            default_name = os.path.splitext(self.xml_path)[0] + ".pdf"

        file_path, _ = QFileDialog.getSaveFileName(self, "PDF保存", default_name, "PDF Files (*.pdf)")
        if not file_path:
            return

        layout = self.page_layout_tsukuru()

        def on_pdf_finished(success):
            try:
                self.web_gamen.page().pdfPrintingFinished.disconnect(on_pdf_finished)
            except Exception:
                pass

            if success:
                QMessageBox.information(self, "完了", f"PDF保存完了\n{file_path}")
            else:
                QMessageBox.critical(self, "エラー", "PDF保存に失敗しました")

        self.web_gamen.page().pdfPrintingFinished.connect(on_pdf_finished)
        self.web_gamen.page().printToPdf(file_path, layout)

    def insatsu_suru(self):
        printer = QPrinter(QPrinter.HighResolution)
        printer.setFullPage(True)
        printer.setPageLayout(self.page_layout_tsukuru())
        dialog = QPrintDialog(printer, self)
        if dialog.exec() != QPrintDialog.Accepted:
            return
        printer.setFullPage(True)
        printer.setPageLayout(self.page_layout_tsukuru())

        fd, temp_path = tempfile.mkstemp(suffix=".pdf")
        os.close(fd)

        def on_pdf_ready(success):
            try:
                self.web_gamen.page().pdfPrintingFinished.disconnect(on_pdf_ready)
            except Exception:
                pass

            if not success:
                QMessageBox.critical(self, "エラー", "印刷用データの生成に失敗しました")
                try:
                    os.remove(temp_path)
                except Exception:
                    pass
                return

            try:
                doc = QPdfDocument(self)
                doc.load(temp_path)

                painter = QPainter()
                if painter.begin(printer):
                    for page_index in range(doc.pageCount()):
                        if page_index > 0:
                            printer.newPage()
                        layout = printer.pageLayout()
                        rect = layout.fullRectPixels(printer.resolution())
                        source_size = doc.pagePointSize(page_index).toSize()
                        if source_size.isEmpty():
                            source_size = rect.size()
                        target_size = QSize(source_size)
                        target_size.scale(rect.size(), Qt.KeepAspectRatio)
                        page_image = doc.render(page_index, target_size)
                        if page_image.isNull():
                            continue
                        x_pos = rect.x() + max(0, (rect.width() - target_size.width()) // 2)
                        y_pos = rect.y() + max(0, (rect.height() - target_size.height()) // 2)
                        target_rect = QRect(x_pos, y_pos, target_size.width(), target_size.height())
                        painter.drawImage(target_rect, page_image)
                    painter.end()
                    QMessageBox.information(self, "完了", "印刷が完了しました")
                else:
                    QMessageBox.critical(self, "エラー", "プリンタの初期化に失敗しました")
            except Exception as error:
                QMessageBox.critical(self, "エラー", f"印刷処理中にエラーが発生しました:\n{error}")
            finally:
                try:
                    os.remove(temp_path)
                except Exception:
                    pass

        layout = self.page_layout_tsukuru()

        self.web_gamen.page().pdfPrintingFinished.connect(on_pdf_ready)
        self.web_gamen.page().printToPdf(temp_path, layout)


if __name__ == "__main__":
    app = QApplication(sys.argv)
    
    # まず画面(window)を作ります
    window = MainApp()
    
    # exe化するとパスが変わるため、相対パスだと画像を見失うことがあります
    if getattr(sys, 'frozen', False):
        # exeから実行されている場合
        base_path = sys._MEIPASS
    else:
        # 通常のpythonコマンドで実行されている場合
        base_path = os.path.dirname(os.path.abspath(__file__))
        
    icon_path = os.path.join(base_path, "assets", "xml_viewer_icon.ico")
    
    if os.path.exists(icon_path):
        # アプリ全体、およびウィンドウにアイコンを設定
        app.setWindowIcon(QIcon(icon_path))
        window.setWindowIcon(QIcon(icon_path))
        
    window.show()
    sys.exit(app.exec())

レイアウトが崩れるので、見た目で成型するのに苦労しています(今でも完ぺきではない)
上記を実行ファイルにしたものを置いておきますので、ご入用の方はどうぞ。
xml_view.zip
例によってサポートは致しませんが、なにかご意見をお送りいただければ、気が向いたら修正します。


【免責事項】
本ツールは無保証で提供されます。利用による損害について開発者は一切の責任を負いません。
また、本ツールに関する個別のサポートやお問い合わせへの対応は行っておりません。
あらかじめご了承の上、自己責任でご利用ください。