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())