numbers2xlsx.py ダウンロード/コピー

numbers2xlsx.py をダウンロード

numbers2xlsx.py
numbers2xlsx.py
  1#!/usr/bin/env python3
  2"""
  3概要:
  4    Apple NumbersドキュメントをExcelのxlsxワークブックに変換するスクリプトです。
  5詳細説明:
  6    既定の処理では、Numbersの1つの表をExcelの1つのワークシートとしてマッピングします。
  7    これにより、1つのシートに複数の表を配置できるNumbers特有のレイアウトによる曖昧さを回避します。
  8    実行には numbers-parser と openpyxl が必要です。
  9"""
 10
 11from __future__ import annotations
 12
 13import argparse
 14import math
 15import os
 16import re
 17import sys
 18import tempfile
 19from dataclasses import dataclass, field
 20from datetime import date, datetime, time, timedelta, timezone
 21from decimal import Decimal
 22from pathlib import Path
 23from typing import Any, Iterable
 24
 25try:
 26    from numbers_parser import Document
 27except ImportError as exc:  # pragma: no cover - exercised only without dependency
 28    raise SystemExit(
 29        "numbers-parser がインストールされていません。\n"
 30        "  python -m pip install numbers-parser openpyxl"
 31    ) from exc
 32
 33try:
 34    from openpyxl import Workbook, load_workbook
 35    from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE
 36    from openpyxl.comments import Comment
 37    from openpyxl.styles import Alignment as XLAlignment
 38    from openpyxl.styles import Border as XLBorder
 39    from openpyxl.styles import Font, PatternFill, Side
 40    from openpyxl.utils import get_column_letter
 41except ImportError as exc:  # pragma: no cover - exercised only without dependency
 42    raise SystemExit(
 43        "openpyxl がインストールされていません。\n"
 44        "  python -m pip install openpyxl"
 45    ) from exc
 46
 47
 48INVALID_SHEET_CHARS_RE = re.compile(r"[\\/*?:\[\]]")
 49MAX_SHEET_TITLE = 31
 50MAX_CELL_TEXT = 32767
 51
 52
 53@dataclass
 54class ConversionStats:
 55    """
 56    概要:
 57        変換処理の統計情報と警告メッセージを保持するデータクラスです。
 58    引数:
 59        :param source_sheets: 入力ドキュメント内のシート数
 60        :type source_sheets: int
 61        :param source_tables: 入力ドキュメント内の表の数
 62        :type source_tables: int
 63        :param output_sheets: 出力されたExcelのシート数
 64        :type output_sheets: int
 65        :param cells_written: 出力されたセルの数
 66        :type cells_written: int
 67        :param formulas_found: 見つかった数式の数
 68        :type formulas_found: int
 69        :param formulas_written: 出力された数式の数
 70        :type formulas_written: int
 71        :param formula_comments: コメントとして出力された数式の数
 72        :type formula_comments: int
 73        :param merged_ranges: 結合されたセルの範囲の数
 74        :type merged_ranges: int
 75        :param styles_applied: 適用された書式の数
 76        :type styles_applied: int
 77        :param hyperlinks_written: 出力されたハイパーリンクの数
 78        :type hyperlinks_written: int
 79        :param warnings: 発生した警告メッセージのリスト
 80        :type warnings: list[str]
 81    """
 82    source_sheets: int = 0
 83    source_tables: int = 0
 84    output_sheets: int = 0
 85    cells_written: int = 0
 86    formulas_found: int = 0
 87    formulas_written: int = 0
 88    formula_comments: int = 0
 89    merged_ranges: int = 0
 90    styles_applied: int = 0
 91    hyperlinks_written: int = 0
 92    warnings: list[str] = field(default_factory=list)
 93
 94    def warn(self, message: str, limit: int = 50) -> None:
 95        """
 96        概要:
 97            コンソール出力が膨大になるのを防ぎつつ、警告メッセージを記録します。
 98        引数:
 99            :param message: 記録する警告メッセージ
100            :type message: str
101            :param limit: 保持する警告メッセージの最大数
102            :type limit: int
103        戻り値:
104            :returns: なし
105            :rtype: None
106        """
107        if len(self.warnings) < limit:
108            self.warnings.append(message)
109        elif len(self.warnings) == limit:
110            self.warnings.append("以降の警告は省略しました。")
111
112
113class SheetNameAllocator:
114    """
115    概要:
116        有効かつ一意なExcelワークシート名を作成するクラスです。
117    詳細説明:
118        シート名の重複や、無効な文字が含まれるのを防ぎます。
119    """
120
121    def __init__(self) -> None:
122        self._used: set[str] = set()
123
124    @staticmethod
125    def _clean(name: str) -> str:
126        """
127        概要:
128            シート名から無効な文字を取り除きます。
129        引数:
130            :param name: 元のシート名
131            :type name: str
132        戻り値:
133            :returns: クリーンアップされたシート名
134            :rtype: str
135        """
136        name = INVALID_SHEET_CHARS_RE.sub("_", str(name or "")).strip()
137        name = name.strip("'")
138        return name or "Sheet"
139
140    def allocate(self, requested: str) -> str:
141        """
142        概要:
143            要求されたシート名を元に、一意となるシート名を割り当てます。
144        引数:
145            :param requested: 希望するシート名
146            :type requested: str
147        戻り値:
148            :returns: 重複を回避した有効なシート名
149            :rtype: str
150        """
151        base = self._clean(requested)[:MAX_SHEET_TITLE]
152        candidate = base
153        index = 2
154        while candidate.casefold() in self._used:
155            suffix = f"_{index}"
156            candidate = f"{base[: MAX_SHEET_TITLE - len(suffix)]}{suffix}"
157            index += 1
158        self._used.add(candidate.casefold())
159        return candidate
160
161
162def rgb_to_argb(color: Any) -> str | None:
163    """
164    概要:
165        numbers-parserのRGB形式の色データをopenpyxlのARGB形式に変換します。
166    引数:
167        :param color: 変換対象の色データ
168        :type color: Any
169    戻り値:
170        :returns: 変換後のARGB文字列またはNone
171        :rtype: str | None
172    """
173    if color is None:
174        return None
175    try:
176        r, g, b = int(color.r), int(color.g), int(color.b)
177    except (AttributeError, TypeError, ValueError):
178        try:
179            r, g, b = (int(component) for component in color[:3])
180        except (TypeError, ValueError, IndexError):
181            return None
182    r, g, b = (min(255, max(0, component)) for component in (r, g, b))
183    return f"FF{r:02X}{g:02X}{b:02X}"
184
185
186def enum_name(value: Any) -> str:
187    """
188    概要:
189        列挙型のような値から、安定した小文字の名前を取得します。
190    引数:
191        :param value: 名前を取得する対象のオブジェクト
192        :type value: Any
193    戻り値:
194        :returns: 小文字の名前文字列
195        :rtype: str
196    """
197    name = getattr(value, "name", None)
198    if name:
199        return str(name).lower()
200    return str(value).split(".")[-1].lower()
201
202
203def make_font(style: Any) -> Font:
204    """
205    概要:
206        openpyxlのFontオブジェクトを作成します。
207    引数:
208        :param style: 元のスタイル情報
209        :type style: Any
210    戻り値:
211        :returns: 生成されたFontオブジェクト
212        :rtype: openpyxl.styles.Font
213    """
214    underline = "single" if bool(getattr(style, "underline", False)) else None
215    return Font(
216        name=getattr(style, "font_name", None) or None,
217        size=float(getattr(style, "font_size", 11.0) or 11.0),
218        bold=bool(getattr(style, "bold", False)),
219        italic=bool(getattr(style, "italic", False)),
220        strike=bool(getattr(style, "strikethrough", False)),
221        underline=underline,
222        color=rgb_to_argb(getattr(style, "font_color", None)),
223    )
224
225
226def make_fill(style: Any) -> PatternFill:
227    """
228    概要:
229        openpyxlのPatternFillオブジェクトを作成します。
230    引数:
231        :param style: 元のスタイル情報
232        :type style: Any
233    戻り値:
234        :returns: 生成されたPatternFillオブジェクト
235        :rtype: openpyxl.styles.PatternFill
236    """
237    background = getattr(style, "bg_color", None)
238    if isinstance(background, list):
239        background = background[0] if background else None
240    color = rgb_to_argb(background)
241    if color is None:
242        return PatternFill(fill_type=None)
243    return PatternFill(fill_type="solid", fgColor=color, bgColor=color)
244
245
246def make_alignment(style: Any) -> XLAlignment:
247    """
248    概要:
249        openpyxlのAlignmentオブジェクトを作成します。
250    引数:
251        :param style: 元のスタイル情報
252        :type style: Any
253    戻り値:
254        :returns: 生成されたAlignmentオブジェクト
255        :rtype: openpyxl.styles.Alignment
256    """
257    source = getattr(style, "alignment", None)
258    horizontal = enum_name(getattr(source, "horizontal", "auto"))
259    vertical = enum_name(getattr(source, "vertical", "top"))
260
261    horizontal_map = {
262        "auto": None,
263        "left": "left",
264        "center": "center",
265        "right": "right",
266        "justify": "justify",
267        "justified": "justify",
268    }
269    vertical_map = {
270        "top": "top",
271        "middle": "center",
272        "center": "center",
273        "bottom": "bottom",
274    }
275    return XLAlignment(
276        horizontal=horizontal_map.get(horizontal),
277        vertical=vertical_map.get(vertical, "top"),
278        wrap_text=bool(getattr(style, "text_wrap", True)),
279    )
280
281
282def border_style(source_side: Any) -> str | None:
283    """
284    概要:
285        境界線のスタイル文字列を決定します。
286    引数:
287        :param source_side: 境界線の情報
288        :type source_side: Any
289    戻り値:
290        :returns: openpyxl用の境界線スタイル名またはNone
291        :rtype: str | None
292    """
293    if source_side is None:
294        return None
295    raw_style = getattr(source_side, "style", None)
296    width = float(getattr(source_side, "width", 0.35) or 0.35)
297
298    # numbers-parser currently stores solid/dashes/dots as 0/1/2.
299    if raw_style == 1 or enum_name(raw_style) in {"dashes", "dashed"}:
300        return "mediumDashed" if width > 1.5 else "dashed"
301    if raw_style == 2 or enum_name(raw_style) in {"dots", "dotted"}:
302        return "dotted"
303    if width <= 0.35:
304        return "hair"
305    if width <= 1.0:
306        return "thin"
307    if width <= 2.0:
308        return "medium"
309    return "thick"
310
311
312def make_side(source_side: Any) -> Side:
313    """
314    概要:
315        openpyxlのSideオブジェクトを作成します。
316    引数:
317        :param source_side: 境界線の情報
318        :type source_side: Any
319    戻り値:
320        :returns: 生成されたSideオブジェクト
321        :rtype: openpyxl.styles.Side
322    """
323    if source_side is None:
324        return Side(style=None)
325    return Side(
326        style=border_style(source_side),
327        color=rgb_to_argb(getattr(source_side, "color", None)) or "FF000000",
328    )
329
330
331def make_border(source_border: Any) -> XLBorder:
332    """
333    概要:
334        openpyxlのBorderオブジェクトを作成します。
335    引数:
336        :param source_border: 元の境界線情報
337        :type source_border: Any
338    戻り値:
339        :returns: 生成されたBorderオブジェクト
340        :rtype: openpyxl.styles.Border
341    """
342    if source_border is None:
343        return XLBorder()
344    return XLBorder(
345        left=make_side(getattr(source_border, "left", None)),
346        right=make_side(getattr(source_border, "right", None)),
347        top=make_side(getattr(source_border, "top", None)),
348        bottom=make_side(getattr(source_border, "bottom", None)),
349    )
350
351
352def sanitize_text(value: str, stats: ConversionStats, location: str) -> str:
353    """
354    概要:
355        テキストから無効な文字を削除し、上限文字数に切り詰めます。
356    引数:
357        :param value: 対象のテキスト
358        :type value: str
359        :param stats: 統計情報オブジェクト
360        :type stats: ConversionStats
361        :param location: エラー報告用のセル位置などの情報
362        :type location: str
363    戻り値:
364        :returns: サニタイズされたテキスト
365        :rtype: str
366    """
367    cleaned = ILLEGAL_CHARACTERS_RE.sub("", value)
368    if cleaned != value:
369        stats.warn(f"{location}: Excelで使用できない制御文字を削除しました。")
370    if len(cleaned) > MAX_CELL_TEXT:
371        stats.warn(f"{location}: 文字列をExcelの上限 {MAX_CELL_TEXT} 文字に切り詰めました。")
372        cleaned = cleaned[:MAX_CELL_TEXT]
373    return cleaned
374
375
376def normalize_value(value: Any, stats: ConversionStats, location: str) -> Any:
377    """
378    概要:
379        Numbersのセル値をopenpyxlと互換性のある値に変換します。
380    引数:
381        :param value: 元のセル値
382        :type value: Any
383        :param stats: 統計情報オブジェクト
384        :type stats: ConversionStats
385        :param location: エラー報告用のセル位置などの情報
386        :type location: str
387    戻り値:
388        :returns: 変換後の値
389        :rtype: Any
390    """
391    if isinstance(value, datetime):
392        if value.tzinfo is not None:
393            stats.warn(f"{location}: タイムゾーン付き日時をUTCのタイムゾーンなし日時へ変換しました。")
394            return value.astimezone(timezone.utc).replace(tzinfo=None)
395        return value
396    if value is None or isinstance(value, (bool, int, date, time, timedelta)):
397        return value
398    if isinstance(value, Decimal):
399        return float(value)
400    if isinstance(value, float):
401        if math.isnan(value):
402            return "NaN"
403        if math.isinf(value):
404            return "Infinity" if value > 0 else "-Infinity"
405        return value
406    if isinstance(value, str):
407        return sanitize_text(value, stats, location)
408    return sanitize_text(str(value), stats, location)
409
410
411def normalize_formula(formula: str, stats: ConversionStats, location: str) -> str:
412    """
413    概要:
414        数式テキストをExcel形式に正規化します。
415    引数:
416        :param formula: 元の数式
417        :type formula: str
418        :param stats: 統計情報オブジェクト
419        :type stats: ConversionStats
420        :param location: エラー報告用のセル位置などの情報
421        :type location: str
422    戻り値:
423        :returns: イコール記号で始まる数式文字列
424        :rtype: str
425    """
426    formula = sanitize_text(formula.strip(), stats, location)
427    return formula if formula.startswith("=") else f"={formula}"
428
429
430def apply_source_style(xl_cell: Any, source_cell: Any, stats: ConversionStats) -> None:
431    """
432    概要:
433        サポートされているNumbersのセル書式と境界線をExcelのセルに適用します。
434    引数:
435        :param xl_cell: 適用先のopenpyxlセルオブジェクト
436        :type xl_cell: Any
437        :param source_cell: 適用元のNumbersセルオブジェクト
438        :type source_cell: Any
439        :param stats: 統計情報オブジェクト
440        :type stats: ConversionStats
441    戻り値:
442        :returns: なし
443        :rtype: None
444    """
445    try:
446        style = source_cell.style
447        if style is not None:
448            xl_cell.font = make_font(style)
449            xl_cell.fill = make_fill(style)
450            xl_cell.alignment = make_alignment(style)
451        border = source_cell.border
452        if border is not None:
453            xl_cell.border = make_border(border)
454        stats.styles_applied += 1
455    except Exception as exc:  # Style conversion must not abort the data conversion.
456        stats.warn(f"{xl_cell.coordinate}: 書式を一部変換できませんでした ({exc})")
457
458
459def add_hyperlink(xl_cell: Any, source_cell: Any, stats: ConversionStats) -> None:
460    """
461    概要:
462        単純な単一のハイパーリンクをExcelのセルに保存します。
463    引数:
464        :param xl_cell: 適用先のopenpyxlセルオブジェクト
465        :type xl_cell: Any
466        :param source_cell: 適用元のNumbersセルオブジェクト
467        :type source_cell: Any
468        :param stats: 統計情報オブジェクト
469        :type stats: ConversionStats
470    戻り値:
471        :returns: なし
472        :rtype: None
473    """
474    try:
475        hyperlinks = getattr(source_cell, "hyperlinks", None)
476        if not hyperlinks or len(hyperlinks) != 1:
477            return
478        _text, url = hyperlinks[0]
479        if not url:
480            return
481        xl_cell.hyperlink = str(url)
482        stats.hyperlinks_written += 1
483    except Exception:
484        # Rich text may contain several partial-cell links; Excel cannot represent
485        # those through a normal cell hyperlink without rebuilding rich text.
486        return
487
488
489def table_dimensions(table: Any) -> tuple[int, int]:
490    """
491    概要:
492        表の行数と列数を取得します。
493    引数:
494        :param table: 対象の表オブジェクト
495        :type table: Any
496    戻り値:
497        :returns: 行数と列数のタプル
498        :rtype: tuple[int, int]
499    """
500    rows = table.rows(values_only=False)
501    return len(rows), max((len(row) for row in rows), default=0)
502
503
504def convert_table(
505    table: Any,
506    worksheet: Any,
507    stats: ConversionStats,
508    *,
509    formula_mode: str,
510    formatted_values: bool,
511    copy_styles: bool,
512    copy_merges: bool,
513    freeze_headers: bool,
514) -> None:
515    """
516    概要:
517        Numbersの表をExcelのワークシートに変換します。
518    引数:
519        :param table: 変換元のNumbersの表オブジェクト
520        :type table: Any
521        :param worksheet: 出力先のopenpyxlワークシートオブジェクト
522        :type worksheet: Any
523        :param stats: 統計情報オブジェクト
524        :type stats: ConversionStats
525        :param formula_mode: 数式の処理モード
526        :type formula_mode: str
527        :param formatted_values: 値を表示形式の文字列として扱うかどうか
528        :type formatted_values: bool
529        :param copy_styles: スタイルをコピーするかどうか
530        :type copy_styles: bool
531        :param copy_merges: 結合セルの情報をコピーするかどうか
532        :type copy_merges: bool
533        :param freeze_headers: ヘッダー行や列を固定するかどうか
534        :type freeze_headers: bool
535    戻り値:
536        :returns: なし
537        :rtype: None
538    """
539    source_rows = table.rows(values_only=False)
540    row_count = len(source_rows)
541    col_count = max((len(row) for row in source_rows), default=0)
542
543    for row_index, source_row in enumerate(source_rows, start=1):
544        for col_index, source_cell in enumerate(source_row, start=1):
545            location = f"{worksheet.title}!{get_column_letter(col_index)}{row_index}"
546            xl_cell = worksheet.cell(row=row_index, column=col_index)
547
548            is_formula = bool(getattr(source_cell, "is_formula", False))
549            formula = getattr(source_cell, "formula", None) if is_formula else None
550            if is_formula:
551                stats.formulas_found += 1
552
553            if formula_mode == "formulas" and formula:
554                xl_cell.value = normalize_formula(formula, stats, location)
555                stats.formulas_written += 1
556            else:
557                raw_value = (
558                    getattr(source_cell, "formatted_value", None)
559                    if formatted_values and getattr(source_cell, "value", None) is not None
560                    else getattr(source_cell, "value", None)
561                )
562                xl_cell.value = normalize_value(raw_value, stats, location)
563                if formula_mode == "comments" and formula:
564                    xl_cell.comment = Comment(
565                        f"Original Numbers formula:\n{formula}",
566                        "numbers2xlsx",
567                    )
568                    stats.formula_comments += 1
569
570            if copy_styles:
571                apply_source_style(xl_cell, source_cell, stats)
572            add_hyperlink(xl_cell, source_cell, stats)
573            stats.cells_written += 1
574
575    # Numbers and Excel both express row height in points.
576    for row_index in range(row_count):
577        try:
578            height = table.row_height(row_index)
579            if height and float(height) > 0:
580                worksheet.row_dimensions[row_index + 1].height = float(height)
581        except Exception as exc:
582            stats.warn(f"{worksheet.title}: {row_index + 1}行目の高さを変換できませんでした ({exc})")
583
584    # Numbers returns points; Excel column width is approximately character units.
585    # 1 point ~= 1.333 pixels and a typical character is about 7 pixels.
586    for col_index in range(col_count):
587        try:
588            width_points = float(table.col_width(col_index))
589            excel_width = max(0.1, min(255.0, width_points * 1.333333 / 7.0))
590            worksheet.column_dimensions[get_column_letter(col_index + 1)].width = excel_width
591        except Exception as exc:
592            stats.warn(
593                f"{worksheet.title}: {get_column_letter(col_index + 1)}列の幅を変換できませんでした ({exc})"
594            )
595
596    if copy_merges:
597        for merge_range in getattr(table, "merge_ranges", []) or []:
598            try:
599                worksheet.merge_cells(str(merge_range))
600                stats.merged_ranges += 1
601            except Exception as exc:
602                stats.warn(f"{worksheet.title}: 結合範囲 {merge_range} を変換できませんでした ({exc})")
603
604    if freeze_headers:
605        header_rows = int(getattr(table, "num_header_rows", 0) or 0)
606        header_cols = int(getattr(table, "num_header_cols", 0) or 0)
607        if header_rows > 0 or header_cols > 0:
608            worksheet.freeze_panes = worksheet.cell(
609                row=min(row_count + 1, header_rows + 1),
610                column=min(max(1, col_count + 1), header_cols + 1),
611            )
612
613    worksheet.sheet_view.showGridLines = True
614    worksheet.page_setup.fitToWidth = 1
615    worksheet.sheet_properties.pageSetUpPr.fitToPage = True
616    worksheet.print_options.horizontalCentered = False
617
618
619def preferred_sheet_name(sheet: Any, table: Any) -> str:
620    """
621    概要:
622        シート名と表の名前から、望ましいExcelワークシート名を作成します。
623    引数:
624        :param sheet: 元のシートオブジェクト
625        :type sheet: Any
626        :param table: 元の表オブジェクト
627        :type table: Any
628    戻り値:
629        :returns: 望ましいシート名文字列
630        :rtype: str
631    """
632    tables = list(sheet.tables)
633    if len(tables) == 1:
634        return str(sheet.name)
635    return f"{sheet.name} - {table.name}"
636
637
638def build_workbook(
639    input_path: Path,
640    stats: ConversionStats,
641    *,
642    formula_mode: str,
643    formatted_values: bool,
644    copy_styles: bool,
645    copy_merges: bool,
646    freeze_headers: bool,
647) -> Workbook:
648    """
649    概要:
650        入力パスからNumbersドキュメントを読み込み、Excelのワークブックを構築します。
651    引数:
652        :param input_path: 入力ファイルへのパス
653        :type input_path: pathlib.Path
654        :param stats: 統計情報オブジェクト
655        :type stats: ConversionStats
656        :param formula_mode: 数式の処理モード
657        :type formula_mode: str
658        :param formatted_values: 値を表示形式の文字列として扱うかどうか
659        :type formatted_values: bool
660        :param copy_styles: スタイルをコピーするかどうか
661        :type copy_styles: bool
662        :param copy_merges: 結合セルの情報をコピーするかどうか
663        :type copy_merges: bool
664        :param freeze_headers: ヘッダー行や列を固定するかどうか
665        :type freeze_headers: bool
666    戻り値:
667        :returns: 構築されたopenpyxlワークブック
668        :rtype: openpyxl.Workbook
669    """
670    document = Document(input_path)
671    workbook = Workbook()
672    workbook.remove(workbook.active)
673    workbook.properties.title = input_path.stem
674    workbook.properties.subject = "Converted from Apple Numbers"
675    workbook.properties.creator = "numbers2xlsx.py"
676
677    allocator = SheetNameAllocator()
678    stats.source_sheets = len(document.sheets)
679
680    for source_sheet in document.sheets:
681        tables = list(source_sheet.tables)
682        if not tables:
683            title = allocator.allocate(str(source_sheet.name))
684            worksheet = workbook.create_sheet(title)
685            worksheet["A1"] = "(Numbersシート内に表がありません)"
686            stats.output_sheets += 1
687            continue
688
689        for table in tables:
690            stats.source_tables += 1
691            requested = preferred_sheet_name(source_sheet, table)
692            title = allocator.allocate(requested)
693            worksheet = workbook.create_sheet(title)
694            convert_table(
695                table,
696                worksheet,
697                stats,
698                formula_mode=formula_mode,
699                formatted_values=formatted_values,
700                copy_styles=copy_styles,
701                copy_merges=copy_merges,
702                freeze_headers=freeze_headers,
703            )
704            stats.output_sheets += 1
705
706    if not workbook.worksheets:
707        workbook.create_sheet("Sheet")
708        stats.output_sheets = 1
709    return workbook
710
711
712def atomic_save(workbook: Workbook, output_path: Path) -> None:
713    """
714    概要:
715        安全な書き込み処理を用いて、ワークブックを指定したパスに保存します。
716    詳細説明:
717        一時ファイルへ書き込んでからファイル名を変更することで、出力先ファイルの破損を防ぎます。
718    引数:
719        :param workbook: 保存対象のワークブック
720        :type workbook: openpyxl.Workbook
721        :param output_path: 保存先のファイルパス
722        :type output_path: pathlib.Path
723    戻り値:
724        :returns: なし
725        :rtype: None
726    """
727    output_path.parent.mkdir(parents=True, exist_ok=True)
728    fd, temporary_name = tempfile.mkstemp(
729        prefix=f".{output_path.stem}.", suffix=".xlsx", dir=output_path.parent
730    )
731    os.close(fd)
732    temporary_path = Path(temporary_name)
733    try:
734        workbook.save(temporary_path)
735        # Re-open once so malformed output is detected before replacing the target.
736        check = load_workbook(temporary_path, read_only=True, data_only=False)
737        check.close()
738        os.replace(temporary_path, output_path)
739    finally:
740        temporary_path.unlink(missing_ok=True)
741
742
743def resolve_output(input_path: Path, positional: Path | None, option: Path | None) -> Path:
744    """
745    概要:
746        入力パスと引数情報から出力先ファイルパスを決定します。
747    引数:
748        :param input_path: 入力ファイルパス
749        :type input_path: pathlib.Path
750        :param positional: 位置引数で指定された出力パス
751        :type positional: pathlib.Path | None
752        :param option: オプション引数で指定された出力パス
753        :type option: pathlib.Path | None
754    戻り値:
755        :returns: 決定された出力ファイルパス
756        :rtype: pathlib.Path
757    例外:
758        :raises ValueError: 出力先が複数指定された場合
759    """
760    if positional is not None and option is not None:
761        raise ValueError("出力先は第2引数または -o/--output のどちらか一方で指定してください。")
762    output = option or positional or input_path.with_suffix(".xlsx")
763    if output.suffix.lower() != ".xlsx":
764        output = output.with_suffix(".xlsx")
765    return output
766
767
768def parse_args(argv: Iterable[str] | None = None) -> argparse.Namespace:
769    """
770    概要:
771        コマンドライン引数をパースします。
772    引数:
773        :param argv: コマンドライン引数のリスト
774        :type argv: Iterable[str] | None
775    戻り値:
776        :returns: パースされた引数情報
777        :rtype: argparse.Namespace
778    """
779    parser = argparse.ArgumentParser(
780        description="Apple Numbers (.numbers) の各表をExcel (.xlsx)へ変換します。",
781        formatter_class=argparse.RawDescriptionHelpFormatter,
782        epilog=(
783            "既定では、Numbersの1表をExcelの1ワークシートに変換します。\n"
784            "数式はNumbersに保存された計算結果を使用します。"
785        ),
786    )
787    parser.add_argument("input", type=Path, help="入力 .numbers ファイル")
788    parser.add_argument("output_positional", nargs="?", type=Path, help="出力 .xlsx ファイル")
789    parser.add_argument("-o", "--output", type=Path, help="出力 .xlsx ファイル")
790    parser.add_argument(
791        "--formulas",
792        choices=("values", "comments", "formulas"),
793        default="values",
794        help=(
795            "数式セルの扱い: values=計算結果のみ (既定), "
796            "comments=計算結果+コメントに元数式, formulas=数式をExcelへ書く"
797        ),
798    )
799    parser.add_argument(
800        "--formatted-values",
801        action="store_true",
802        help="数値や日時をNumbersの表示文字列として書き出す(データ型は文字列になる)",
803    )
804    parser.add_argument("--no-styles", action="store_true", help="セルの基本書式を変換しない")
805    parser.add_argument("--no-merges", action="store_true", help="結合セルを変換しない")
806    parser.add_argument("--no-freeze", action="store_true", help="ヘッダー行・列を固定しない")
807    parser.add_argument("--overwrite", action="store_true", help="既存の出力ファイルを上書きする")
808    return parser.parse_args(argv)
809
810
811def main(argv: Iterable[str] | None = None) -> int:
812    """
813    概要:
814        スクリプトのメイン処理を実行します。
815    引数:
816        :param argv: コマンドライン引数のリスト
817        :type argv: Iterable[str] | None
818    戻り値:
819        :returns: 終了コード
820        :rtype: int
821    """
822    args = parse_args(argv)
823    input_path: Path = args.input.expanduser().resolve()
824
825    if not input_path.exists():
826        print(f"Error: 入力ファイルが見つかりません: {input_path}", file=sys.stderr)
827        return 2
828    if input_path.suffix.lower() != ".numbers":
829        print(f"Warning: 拡張子が .numbers ではありません: {input_path}", file=sys.stderr)
830
831    try:
832        output_path = resolve_output(input_path, args.output_positional, args.output)
833        output_path = output_path.expanduser().resolve()
834    except ValueError as exc:
835        print(f"Error: {exc}", file=sys.stderr)
836        return 2
837
838    if input_path == output_path:
839        print("Error: 入力と出力に同じパスは指定できません。", file=sys.stderr)
840        return 2
841    if output_path.exists() and not args.overwrite:
842        print(
843            f"Error: 出力ファイルは既に存在します: {output_path}\n"
844            "上書きする場合は --overwrite を指定してください。",
845            file=sys.stderr,
846        )
847        return 2
848    if args.formatted_values and args.formulas == "formulas":
849        print(
850            "Error: --formatted-values と --formulas formulas は同時に指定できません。",
851            file=sys.stderr,
852        )
853        return 2
854
855    stats = ConversionStats()
856    try:
857        workbook = build_workbook(
858            input_path,
859            stats,
860            formula_mode=args.formulas,
861            formatted_values=args.formatted_values,
862            copy_styles=not args.no_styles,
863            copy_merges=not args.no_merges,
864            freeze_headers=not args.no_freeze,
865        )
866        atomic_save(workbook, output_path)
867    except Exception as exc:
868        print(f"Error: 変換に失敗しました: {exc}", file=sys.stderr)
869        return 1
870
871    print(f"Converted: {input_path}")
872    print(f"Output   : {output_path}")
873    print(
874        "Summary  : "
875        f"{stats.source_sheets} source sheet(s), "
876        f"{stats.source_tables} table(s), "
877        f"{stats.output_sheets} Excel worksheet(s), "
878        f"{stats.cells_written} cell(s)"
879    )
880    if stats.formulas_found:
881        print(
882            "Formulas : "
883            f"found={stats.formulas_found}, "
884            f"written={stats.formulas_written}, "
885            f"comments={stats.formula_comments}"
886        )
887    if stats.merged_ranges:
888        print(f"Merges   : {stats.merged_ranges}")
889    if stats.warnings:
890        print("Warnings :", file=sys.stderr)
891        for warning in stats.warnings:
892            print(f"  - {warning}", file=sys.stderr)
893
894    return 0
895
896
897if __name__ == "__main__":
898    raise SystemExit(main())