From 781c9ea6b381923505331f3fc79515ed3646d633 Mon Sep 17 00:00:00 2001 From: harumiWeb Date: Mon, 12 Jan 2026 15:34:50 +0900 Subject: [PATCH 01/12] =?UTF-8?q?=E5=88=9D=E6=9C=9F=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/agents/FEATURE_SPEC.md | 40 ------------------------------------- docs/agents/TASKS.md | 7 ------- uv.lock | 2 +- 3 files changed, 1 insertion(+), 48 deletions(-) diff --git a/docs/agents/FEATURE_SPEC.md b/docs/agents/FEATURE_SPEC.md index 435001e..55753aa 100644 --- a/docs/agents/FEATURE_SPEC.md +++ b/docs/agents/FEATURE_SPEC.md @@ -4,46 +4,6 @@ --- -## セル結合データのコンテキスト量圧縮 - -- 現状の `merged_cells` がコンテキスト量を非常に多く持っているため、データ構造の見直しで圧縮する -- `rows` と `merged_cells` でセル値を重複して持っているため、出力時に `rows` 側の結合セル値を落とす運用を検討する - -### 仕様(v1.1 予定) - -- `merged_cells` を **schema + items** 形式へ変更して冗長なキーを削減する -- 結合セルの値は `merged_cells` に集約し、`rows` 側に保持するかはフラグで切替可能にする - -#### merged_cells の新フォーマット(例) - -```json -{ - "merged_cells": { - "schema": ["r1", "c1", "r2", "c2", "v"], - "items": [ - [1, 0, 2, 1, "A1-B2 merged"], - [3, 4, 3, 6, "merged value"] - ] - } -} -``` - -- `r1/c1/r2/c2` は従来同様の座標(row: 1-based, col: 0-based) -- `v` は結合セルの代表値(セル値がない場合でも `" "` を出力する) - -#### rows 側の結合セル値の扱い - -- 新しいフラグ `include_merged_values_in_rows: bool` を導入 -- `True` の場合は互換モード(従来どおり `rows` に結合セル値を残す) -- `False` の場合は `rows` から結合セル値を排除し、`merged_cells` のみで値を保持 - -#### 互換性 - -- デフォルトは `True` として破壊的変更を回避 -- 将来的にデフォルト切替の可能性があるため、出力仕様に明記する - ---- - ## 今後のオプション検討メモ - 表検知スコアリングの閾値を CLI/環境変数で調整可能にする diff --git a/docs/agents/TASKS.md b/docs/agents/TASKS.md index 4a643d7..6f461d3 100644 --- a/docs/agents/TASKS.md +++ b/docs/agents/TASKS.md @@ -2,10 +2,3 @@ 未完了 [ ], 完了 [x] -- [x] 仕様: `merged_cells` の新フォーマット(schema + items)をモデルと出力仕様に反映 -- [x] 仕様: `include_merged_values_in_rows` フラグ追加(デフォルト True) -- [x] 実装: 既存の `merged_cells` 生成ロジックを新構造へ置換 -- [x] 実装: `rows` から結合セル値を排除する分岐を追加(フラグ制御) -- [x] 実装: 結合セルの値がない場合は `" "` を出力 -- [ ] 更新: 既存の JSON 出力例・ドキュメントの整合性確認 -- [x] テスト: 結合セルが多いケースの JSON 量削減を確認 diff --git a/uv.lock b/uv.lock index 9bfcbde..29c3683 100644 --- a/uv.lock +++ b/uv.lock @@ -298,7 +298,7 @@ wheels = [ [[package]] name = "exstruct" -version = "0.3.2" +version = "0.3.5" source = { editable = "." } dependencies = [ { name = "numpy" }, From 9f8c0891cf9bb1d9a7f8900832fb8c459a229ddf Mon Sep 17 00:00:00 2001 From: harumiWeb Date: Mon, 12 Jan 2026 15:43:53 +0900 Subject: [PATCH 02/12] =?UTF-8?q?=E7=94=BB=E5=83=8F=E5=87=BA=E5=8A=9B?= =?UTF-8?q?=E3=82=92=E3=82=B7=E3=83=BC=E3=83=88=E5=8D=98=E4=BD=8D=E3=81=AE?= =?UTF-8?q?PDF=E3=81=AB=E5=88=87=E3=82=8A=E6=9B=BF=E3=81=88=E3=80=81PDF?= =?UTF-8?q?=E3=83=9A=E3=83=BC=E3=82=B8=E6=95=B0=E3=81=A7=E5=85=A8=E3=83=9A?= =?UTF-8?q?=E3=83=BC=E3=82=B8=E3=82=92PNG=E5=8C=96=E3=81=99=E3=82=8B?= =?UTF-8?q?=E3=82=88=E3=81=86=E3=81=AB=E4=BF=AE=E6=AD=A3=E3=81=97=E3=81=BE?= =?UTF-8?q?=E3=81=97=E3=81=9F=E3=80=822=E6=9E=9A=E7=9B=AE=E4=BB=A5?= =?UTF-8?q?=E9=99=8D=E3=81=AF=20=5Fp02=20=E3=81=AE=E3=82=88=E3=81=86?= =?UTF-8?q?=E3=81=AB=E3=83=9A=E3=83=BC=E3=82=B8=E7=95=AA=E5=8F=B7=E3=82=92?= =?UTF-8?q?=E4=BB=98=E4=B8=8E=E3=81=97=E3=81=BE=E3=81=99=E3=80=82=5F=5Fini?= =?UTF-8?q?t=5F=5F.py=20=E3=81=A7=E3=80=81Excel=20COM=20=E3=81=8B=E3=82=89?= =?UTF-8?q?=E5=90=84=E3=82=B7=E3=83=BC=E3=83=88=E3=82=92=E5=80=8B=E5=88=A5?= =?UTF-8?q?PDF=E3=81=A8=E3=81=97=E3=81=A6=E6=9B=B8=E3=81=8D=E5=87=BA?= =?UTF-8?q?=E3=81=97=E3=80=81PDF=E3=83=9A=E3=83=BC=E3=82=B8=E3=82=92?= =?UTF-8?q?=E9=A0=86=E3=81=AB=E3=83=AC=E3=83=B3=E3=83=80=E3=83=AA=E3=83=B3?= =?UTF-8?q?=E3=82=B0=E3=81=97=E3=81=A6=E4=BF=9D=E5=AD=98=E3=81=99=E3=82=8B?= =?UTF-8?q?=E5=AE=9F=E8=A3=85=E3=81=AB=E5=A4=89=E6=9B=B4=E3=81=97=E3=81=A6?= =?UTF-8?q?=E3=81=84=E3=81=BE=E3=81=99=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/agents/TASKS.md | 6 +++ src/exstruct/render/__init__.py | 43 ++++++++++++------ .../assets/multiple_print_ranges_4sheets.xlsx | Bin 0 -> 25687 bytes 3 files changed, 36 insertions(+), 13 deletions(-) create mode 100644 tests/assets/multiple_print_ranges_4sheets.xlsx diff --git a/docs/agents/TASKS.md b/docs/agents/TASKS.md index 6f461d3..6bc0ba5 100644 --- a/docs/agents/TASKS.md +++ b/docs/agents/TASKS.md @@ -2,3 +2,9 @@ 未完了 [ ], 完了 [x] +- [ ] 仕様確認: 画像出力は「シート枚数」ではなく PDF ページ数に合わせて出力する方針を確定(複数印刷範囲=複数ページ前提) +- [ ] 実装方針: `export_sheet_images` を PDF ページ数でループし、シート名は1枚目のみ採用/2枚目以降は `_p02` のようにページ番号を付与する命名規約で出力する +- [ ] 実装: `src/exstruct/render/__init__.py` の画像出力をページ数ベースに修正し、画像ファイル名にページ番号を反映 +- [ ] 互換性確認: 既存の1シート=1ページの場合のファイル名が破壊的にならないか確認 +- [ ] テスト/確認: 手元の複数印刷範囲サンプルで PNG が全ページ出力されることを確認 + diff --git a/src/exstruct/render/__init__.py b/src/exstruct/render/__init__.py index fde18a4..b427241 100644 --- a/src/exstruct/render/__init__.py +++ b/src/exstruct/render/__init__.py @@ -84,21 +84,38 @@ def export_sheet_images( try: with tempfile.TemporaryDirectory() as td: - tmp_pdf = Path(td) / "book.pdf" - sheet_names = export_pdf(normalized_excel_path, tmp_pdf) - scale = dpi / 72.0 written: list[Path] = [] - with pdfium.PdfDocument(str(tmp_pdf)) as pdf: - for i, sheet_name in enumerate(sheet_names): - page = pdf[i] - bitmap = page.render(scale=scale) - pil_image = bitmap.to_pil() - safe_name = _sanitize_sheet_filename(sheet_name) - img_path = normalized_output_dir / f"{i + 1:02d}_{safe_name}.png" - pil_image.save(img_path, format="PNG", dpi=(dpi, dpi)) - written.append(img_path) - return written + app: xw.App | None = None + wb: xw.Book | None = None + try: + app = _require_excel_app() + wb = app.books.open(str(normalized_excel_path)) + for sheet_index, sheet in enumerate(wb.sheets): + sheet_name = sheet.name + sheet_pdf = Path(td) / f"sheet_{sheet_index + 1:02d}.pdf" + sheet.api.ExportAsFixedFormat(0, str(sheet_pdf)) + with pdfium.PdfDocument(str(sheet_pdf)) as pdf: + for page_index in range(len(pdf)): + page = pdf[page_index] + bitmap = page.render(scale=scale) + pil_image = bitmap.to_pil() + safe_name = _sanitize_sheet_filename(sheet_name) + page_suffix = ( + f"_p{page_index + 1:02d}" if page_index > 0 else "" + ) + img_path = ( + normalized_output_dir + / f"{sheet_index + 1:02d}_{safe_name}{page_suffix}.png" + ) + pil_image.save(img_path, format="PNG", dpi=(dpi, dpi)) + written.append(img_path) + return written + finally: + if wb is not None: + wb.close() + if app is not None: + app.quit() except RenderError: raise except Exception as exc: diff --git a/tests/assets/multiple_print_ranges_4sheets.xlsx b/tests/assets/multiple_print_ranges_4sheets.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..114b2f0ea6d5b1a4bbd1982f524512765bcc3b3d GIT binary patch literal 25687 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 z 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 Date: Mon, 12 Jan 2026 15:50:01 +0900 Subject: [PATCH 03/12] =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=82=B1?= =?UTF-8?q?=E3=83=BC=E3=82=B9=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/com/test_render_smoke.py | 22 ++++++++++++++++ tests/render/test_render_init.py | 45 ++++++++++++++++++-------------- 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/tests/com/test_render_smoke.py b/tests/com/test_render_smoke.py index 917a715..d85ec52 100644 --- a/tests/com/test_render_smoke.py +++ b/tests/com/test_render_smoke.py @@ -34,3 +34,25 @@ def test_render_smoke_pdf_and_png(tmp_path: Path) -> None: assert pdf_path.exists() assert images_dir.exists() assert any(images_dir.glob("*.png")) + + +def test_render_multiple_print_ranges_images(tmp_path: Path) -> None: + xlsx = ( + Path(__file__).resolve().parents[1] + / "assets" + / "multiple_print_ranges_4sheets.xlsx" + ) + out_json = tmp_path / "out.json" + process_excel( + xlsx, + output_path=out_json, + out_fmt="json", + image=True, + dpi=72, + mode="standard", + pretty=True, + ) + images_dir = out_json.parent / f"{out_json.stem}_images" + images = list(images_dir.glob("*.png")) + assert images_dir.exists() + assert len(images) == 4 diff --git a/tests/render/test_render_init.py b/tests/render/test_render_init.py index 197ffd5..1058055 100644 --- a/tests/render/test_render_init.py +++ b/tests/render/test_render_init.py @@ -16,6 +16,15 @@ class FakeSheet: def __init__(self, name: str) -> None: self.name = name + self.api = FakeSheetApi() + + +class FakeSheetApi: + """Stub of xlwings Sheet.api for PDF export.""" + + def ExportAsFixedFormat(self, file_format: int, output_path: str) -> None: + _ = file_format + Path(output_path).write_bytes(b"%PDF-1.4") class FakeBookApi: @@ -87,6 +96,7 @@ class FakePdfDocument: def __init__(self, path: str) -> None: self._path = path + self._page_count = 2 if "sheet_01" in path else 1 def __enter__(self) -> FakePdfDocument: return self @@ -106,6 +116,9 @@ def __getitem__(self, index: int) -> FakePage: _ = index return FakePage() + def __len__(self) -> int: + return self._page_count + class FakeImage: """Stub of a PIL image with a save method.""" @@ -242,38 +255,33 @@ def test_export_sheet_images_success( xlsx.write_bytes(b"dummy") out_dir = tmp_path / "images" - def _fake_export_pdf(excel_path: Path, output_pdf: Path) -> list[str]: - _ = excel_path - output_pdf.write_bytes(b"%PDF-1.4") - return ["Sheet/1", " "] - fake_pdfium = SimpleNamespace(PdfDocument=FakePdfDocument) monkeypatch.setattr(render, "_require_pdfium", lambda: fake_pdfium) - monkeypatch.setattr(render, "export_pdf", _fake_export_pdf) + monkeypatch.setattr( + render, "_require_excel_app", lambda: FakeApp(["Sheet/1", " "], False) + ) written = render.export_sheet_images(xlsx, out_dir, dpi=144) assert written[0].name == "01_Sheet_1.png" - assert written[1].name == "02_sheet.png" + assert written[1].name == "01_Sheet_1_p02.png" + assert written[2].name == "02_sheet.png" assert all(path.exists() for path in written) def test_export_sheet_images_propagates_render_error( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """export_sheet_images re-raises RenderError from export_pdf.""" + """export_sheet_images re-raises RenderError from _require_excel_app.""" xlsx = tmp_path / "input.xlsx" xlsx.write_bytes(b"dummy") out_dir = tmp_path / "images" - def _fake_export_pdf(excel_path: Path, output_pdf: Path) -> list[str]: - _ = excel_path - _ = output_pdf - raise RenderError("boom") - fake_pdfium = SimpleNamespace(PdfDocument=FakePdfDocument) monkeypatch.setattr(render, "_require_pdfium", lambda: fake_pdfium) - monkeypatch.setattr(render, "export_pdf", _fake_export_pdf) + monkeypatch.setattr( + render, "_require_excel_app", lambda: (_ for _ in ()).throw(RenderError("boom")) + ) with pytest.raises(RenderError, match="boom"): render.export_sheet_images(xlsx, out_dir) @@ -287,11 +295,6 @@ def test_export_sheet_images_wraps_unknown_error( xlsx.write_bytes(b"dummy") out_dir = tmp_path / "images" - def _fake_export_pdf(excel_path: Path, output_pdf: Path) -> list[str]: - _ = excel_path - output_pdf.write_bytes(b"%PDF-1.4") - return ["Sheet1"] - class ExplodingPdfDocument: """PdfDocument stub that raises on enter.""" @@ -314,7 +317,9 @@ def __exit__( fake_pdfium = SimpleNamespace(PdfDocument=ExplodingPdfDocument) monkeypatch.setattr(render, "_require_pdfium", lambda: fake_pdfium) - monkeypatch.setattr(render, "export_pdf", _fake_export_pdf) + monkeypatch.setattr( + render, "_require_excel_app", lambda: FakeApp(["Sheet1"], False) + ) with pytest.raises(RenderError, match="Failed to export sheet images"): render.export_sheet_images(xlsx, out_dir) From a257a8b92b3ce213788175477788f27b2b2be671 Mon Sep 17 00:00:00 2001 From: harumiWeb Date: Mon, 12 Jan 2026 15:56:29 +0900 Subject: [PATCH 04/12] =?UTF-8?q?=E5=88=9D=E6=9C=9F=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/agents/TASKS.md | 6 ------ tests/assets/sample.xls | Bin 0 -> 112640 bytes 2 files changed, 6 deletions(-) create mode 100644 tests/assets/sample.xls diff --git a/docs/agents/TASKS.md b/docs/agents/TASKS.md index 6bc0ba5..6f461d3 100644 --- a/docs/agents/TASKS.md +++ b/docs/agents/TASKS.md @@ -2,9 +2,3 @@ 未完了 [ ], 完了 [x] -- [ ] 仕様確認: 画像出力は「シート枚数」ではなく PDF ページ数に合わせて出力する方針を確定(複数印刷範囲=複数ページ前提) -- [ ] 実装方針: `export_sheet_images` を PDF ページ数でループし、シート名は1枚目のみ採用/2枚目以降は `_p02` のようにページ番号を付与する命名規約で出力する -- [ ] 実装: `src/exstruct/render/__init__.py` の画像出力をページ数ベースに修正し、画像ファイル名にページ番号を反映 -- [ ] 互換性確認: 既存の1シート=1ページの場合のファイル名が破壊的にならないか確認 -- [ ] テスト/確認: 手元の複数印刷範囲サンプルで PNG が全ページ出力されることを確認 - diff --git a/tests/assets/sample.xls b/tests/assets/sample.xls new file mode 100644 index 0000000000000000000000000000000000000000..480c49c09d312d73f30189b6eef558411b6c7207 GIT binary patch literal 112640 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 aLkYH|W3VHgfxmY=>;A#-Z$$-vF8@EBj!}L9 literal 0 HcmV?d00001 From 8ed4466d1e2a7cd491afbdeba0b13c53ef72cef2 Mon Sep 17 00:00:00 2001 From: harumiWeb Date: Mon, 12 Jan 2026 16:06:53 +0900 Subject: [PATCH 05/12] =?UTF-8?q?xls=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB?= =?UTF-8?q?=E3=81=A7=E7=94=BB=E5=83=8F=E3=82=92=E5=87=BA=E5=8A=9B=E3=81=95?= =?UTF-8?q?=E3=82=8C=E3=81=AA=E3=81=84=E5=95=8F=E9=A1=8C=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E3=80=81SaveAs=E3=82=92=E4=BD=BF=E3=81=A3=E3=81=A6xls?= =?UTF-8?q?x=E5=BD=A2=E5=BC=8F=E3=81=AB=E5=A4=89=E6=8F=9B=E3=81=97?= =?UTF-8?q?=E3=81=A6=E3=81=8B=E3=82=89=E7=94=BB=E5=83=8F=E3=82=A8=E3=82=AF?= =?UTF-8?q?=E3=82=B9=E3=83=9D=E3=83=BC=E3=83=88=E3=81=99=E3=82=8B=E3=82=88?= =?UTF-8?q?=E3=81=86=E3=81=AB=E3=81=97=E3=81=BE=E3=81=97=E3=81=9F=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/agents/TASKS.md | 2 ++ src/exstruct/render/__init__.py | 5 +++-- tests/render/test_render_init.py | 22 +++++++++++++--------- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/docs/agents/TASKS.md b/docs/agents/TASKS.md index 6f461d3..26c4b86 100644 --- a/docs/agents/TASKS.md +++ b/docs/agents/TASKS.md @@ -2,3 +2,5 @@ 未完了 [ ], 完了 [x] +- [ ] 仕様確認: `.xls` の画像出力は可能だが、現状は一時ファイルが `.xlsx` 固定のため失敗する点を明記 +- [ ] 実装方針: `export_pdf` の一時ファイル拡張子を元の拡張子に合わせる(`.xls` なら `book.xls`)か、`SaveAs` で `.xlsx` に変換してから PDF 出力する diff --git a/src/exstruct/render/__init__.py b/src/exstruct/render/__init__.py index b427241..d471524 100644 --- a/src/exstruct/render/__init__.py +++ b/src/exstruct/render/__init__.py @@ -35,14 +35,15 @@ def export_pdf(excel_path: str | Path, output_pdf: str | Path) -> list[str]: temp_dir = Path(td) temp_xlsx = temp_dir / "book.xlsx" temp_pdf = temp_dir / "book.pdf" - shutil.copy(normalized_excel_path, temp_xlsx) app: xw.App | None = None wb: xw.Book | None = None try: app = _require_excel_app() - wb = app.books.open(str(temp_xlsx)) + app.display_alerts = False + wb = app.books.open(str(normalized_excel_path)) sheet_names = [s.name for s in wb.sheets] + wb.api.SaveAs(str(temp_xlsx)) wb.api.ExportAsFixedFormat(0, str(temp_pdf)) shutil.copy(temp_pdf, normalized_output_pdf) except RenderError: diff --git a/tests/render/test_render_init.py b/tests/render/test_render_init.py index 1058055..4dc0fc9 100644 --- a/tests/render/test_render_init.py +++ b/tests/render/test_render_init.py @@ -3,9 +3,11 @@ import builtins from collections.abc import Callable from pathlib import Path +import shutil from types import SimpleNamespace import pytest +import xlwings as xw from exstruct.errors import MissingDependencyError, RenderError import exstruct.render as render @@ -34,6 +36,9 @@ def ExportAsFixedFormat(self, file_format: int, output_path: str) -> None: _ = file_format Path(output_path).write_bytes(b"%PDF-1.4") + def SaveAs(self, output_path: str) -> None: + Path(output_path).write_bytes(b"XLSX") + class FakeBook: """Stub of xlwings Book.""" @@ -68,6 +73,7 @@ class FakeApp: def __init__(self, sheet_names: list[str], raise_on_open: bool) -> None: self.books = FakeBooks(sheet_names, raise_on_open) + self.display_alerts = True self.quit_called = False def quit(self) -> None: @@ -152,7 +158,7 @@ def _factory(*args: object, **kwargs: object) -> FakeApp: def test_require_excel_app_success(monkeypatch: pytest.MonkeyPatch) -> None: """_require_excel_app returns the constructed app instance.""" fake_app = FakeApp(["Sheet1"], raise_on_open=False) - monkeypatch.setattr(render.xw, "App", lambda *a, **k: fake_app) + monkeypatch.setattr(xw, "App", lambda *a, **k: fake_app) assert render._require_excel_app() is fake_app @@ -165,7 +171,7 @@ def _raise(*args: object, **kwargs: object) -> None: _ = kwargs raise RuntimeError("boom") - monkeypatch.setattr(render.xw, "App", _raise) + monkeypatch.setattr(xw, "App", _raise) with pytest.raises(RenderError, match="Excel \\(COM\\) is not available"): render._require_excel_app() @@ -177,7 +183,7 @@ def test_export_pdf_success(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> xlsx.write_bytes(b"dummy") output_pdf = tmp_path / "out.pdf" sheet_names = ["Sheet1", "Summary"] - monkeypatch.setattr(render.xw, "App", _fake_app_factory(sheet_names)) + monkeypatch.setattr(xw, "App", _fake_app_factory(sheet_names)) result = render.export_pdf(xlsx, output_pdf) @@ -192,9 +198,7 @@ def test_export_pdf_wraps_failure( xlsx = tmp_path / "input.xlsx" xlsx.write_bytes(b"dummy") output_pdf = tmp_path / "out.pdf" - monkeypatch.setattr( - render.xw, "App", _fake_app_factory(["Sheet1"], raise_on_open=True) - ) + monkeypatch.setattr(xw, "App", _fake_app_factory(["Sheet1"], raise_on_open=True)) with pytest.raises(RenderError, match="Failed to export PDF for"): render.export_pdf(xlsx, output_pdf) @@ -207,9 +211,9 @@ def test_export_pdf_missing_output_raises( xlsx = tmp_path / "input.xlsx" xlsx.write_bytes(b"dummy") output_pdf = tmp_path / "out.pdf" - monkeypatch.setattr(render.xw, "App", _fake_app_factory(["Sheet1"])) + monkeypatch.setattr(xw, "App", _fake_app_factory(["Sheet1"])) - real_copy = render.shutil.copy + real_copy = shutil.copy def _copy( src: Path | str, dst: Path | str, *args: object, **kwargs: object @@ -220,7 +224,7 @@ def _copy( return Path(dst) return Path(real_copy(src, dst)) - monkeypatch.setattr(render.shutil, "copy", _copy) + monkeypatch.setattr(shutil, "copy", _copy) with pytest.raises(RenderError, match="Failed to export PDF to"): render.export_pdf(xlsx, output_pdf) From 6d5338a6b3499c9e9b1a1141fe46f61baa758b57 Mon Sep 17 00:00:00 2001 From: harumiWeb Date: Mon, 12 Jan 2026 16:19:16 +0900 Subject: [PATCH 06/12] =?UTF-8?q?=E7=94=BB=E5=83=8F=E5=87=BA=E5=8A=9B?= =?UTF-8?q?=E6=99=82=E3=81=AE=E3=83=A1=E3=83=A2=E3=83=AA=E4=B8=8D=E8=B6=B3?= =?UTF-8?q?=E3=82=92=E6=8A=91=E5=88=B6=E3=81=99=E3=82=8B=E3=81=9F=E3=82=81?= =?UTF-8?q?=E3=81=AB=E5=87=A6=E7=90=86=E3=82=92=E3=82=B5=E3=83=96=E3=83=97?= =?UTF-8?q?=E3=83=AD=E3=82=BB=E3=82=B9=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/agents/TASKS.md | 6 +- src/exstruct/render/__init__.py | 130 +++++++++++++++++++++++++++---- tests/render/test_render_init.py | 3 + 3 files changed, 122 insertions(+), 17 deletions(-) diff --git a/docs/agents/TASKS.md b/docs/agents/TASKS.md index 26c4b86..495bd81 100644 --- a/docs/agents/TASKS.md +++ b/docs/agents/TASKS.md @@ -2,5 +2,7 @@ 未完了 [ ], 完了 [x] -- [ ] 仕様確認: `.xls` の画像出力は可能だが、現状は一時ファイルが `.xlsx` 固定のため失敗する点を明記 -- [ ] 実装方針: `export_pdf` の一時ファイル拡張子を元の拡張子に合わせる(`.xls` なら `book.xls`)か、`SaveAs` で `.xlsx` に変換してから PDF 出力する +- [ ] 仕様確認: 画像出力は DPI を維持しつつ、メモリリーク/クラッシュ回避のためサブプロセス化で処理する方針を明記 +- [ ] 実装方針: シートごとに PDF を分割 → サブプロセスで PDF ページを PNG へ変換 → 終了時にメモリを解放する設計(親は進捗/結果を集約) +- [ ] 実装方針: 子プロセスは `pypdfium2` をロードしてページごとにレンダリングし、書き込み済みパスを親に返す +- [ ] 実装方針: 例外時は子プロセスでエラーを返し、親が RenderError として集約して返す diff --git a/src/exstruct/render/__init__.py b/src/exstruct/render/__init__.py index d471524..4573cbf 100644 --- a/src/exstruct/render/__init__.py +++ b/src/exstruct/render/__init__.py @@ -1,6 +1,8 @@ from __future__ import annotations import logging +import multiprocessing as mp +import os from pathlib import Path import shutil import tempfile @@ -78,14 +80,17 @@ def export_sheet_images( excel_path: str | Path, output_dir: str | Path, dpi: int = 144 ) -> list[Path]: """Export each sheet as PNG (via PDF then pypdfium2 rasterization) and return paths in sheet order.""" - pdfium = cast(Any, _require_pdfium()) normalized_excel_path = Path(excel_path) normalized_output_dir = Path(output_dir) normalized_output_dir.mkdir(parents=True, exist_ok=True) + use_subprocess = _use_render_subprocess() + if not use_subprocess: + pdfium = cast(Any, _require_pdfium()) + else: + _require_pdfium() try: with tempfile.TemporaryDirectory() as td: - scale = dpi / 72.0 written: list[Path] = [] app: xw.App | None = None wb: xw.Book | None = None @@ -96,21 +101,28 @@ def export_sheet_images( sheet_name = sheet.name sheet_pdf = Path(td) / f"sheet_{sheet_index + 1:02d}.pdf" sheet.api.ExportAsFixedFormat(0, str(sheet_pdf)) - with pdfium.PdfDocument(str(sheet_pdf)) as pdf: - for page_index in range(len(pdf)): - page = pdf[page_index] - bitmap = page.render(scale=scale) - pil_image = bitmap.to_pil() - safe_name = _sanitize_sheet_filename(sheet_name) - page_suffix = ( - f"_p{page_index + 1:02d}" if page_index > 0 else "" + safe_name = _sanitize_sheet_filename(sheet_name) + if use_subprocess: + written.extend( + _render_pdf_pages_subprocess( + sheet_pdf, + normalized_output_dir, + sheet_index, + safe_name, + dpi, ) - img_path = ( - normalized_output_dir - / f"{sheet_index + 1:02d}_{safe_name}{page_suffix}.png" + ) + else: + written.extend( + _render_pdf_pages_in_process( + pdfium, + sheet_pdf, + normalized_output_dir, + sheet_index, + safe_name, + dpi, ) - pil_image.save(img_path, format="PNG", dpi=(dpi, dpi)) - written.append(img_path) + ) return written finally: if wb is not None: @@ -129,4 +141,92 @@ def _sanitize_sheet_filename(name: str) -> str: return "".join("_" if c in '\\/:*?"<>|' else c for c in name).strip() or "sheet" +def _use_render_subprocess() -> bool: + """Return True when PDF->PNG rendering should run in a subprocess.""" + return os.getenv("EXSTRUCT_RENDER_SUBPROCESS", "1").lower() not in {"0", "false"} + + +def _render_pdf_pages_in_process( + pdfium: ModuleType, + pdf_path: Path, + output_dir: Path, + sheet_index: int, + safe_name: str, + dpi: int, +) -> list[Path]: + """Render PDF pages to PNGs in the current process.""" + scale = dpi / 72.0 + written: list[Path] = [] + with pdfium.PdfDocument(str(pdf_path)) as pdf: + for page_index in range(len(pdf)): + page = pdf[page_index] + bitmap = page.render(scale=scale) + pil_image = bitmap.to_pil() + page_suffix = f"_p{page_index + 1:02d}" if page_index > 0 else "" + img_path = ( + output_dir / f"{sheet_index + 1:02d}_{safe_name}{page_suffix}.png" + ) + pil_image.save(img_path, format="PNG", dpi=(dpi, dpi)) + written.append(img_path) + return written + + +def _render_pdf_pages_subprocess( + pdf_path: Path, + output_dir: Path, + sheet_index: int, + safe_name: str, + dpi: int, +) -> list[Path]: + """Render PDF pages to PNGs in a subprocess for memory isolation.""" + ctx = mp.get_context("spawn") + queue: mp.Queue[dict[str, list[str] | str]] = ctx.Queue() + process = ctx.Process( + target=_render_pdf_pages_worker, + args=(pdf_path, output_dir, sheet_index, safe_name, dpi, queue), + ) + process.start() + process.join() + if not queue.empty(): + result = queue.get() + else: + result = {"error": "subprocess did not return results"} + if process.exitcode != 0 or "error" in result: + message = result.get("error", "subprocess failed") + raise RenderError(f"Failed to render PDF pages: {message}") + paths = result.get("paths", []) + return [Path(path) for path in paths] + + +def _render_pdf_pages_worker( + pdf_path: Path, + output_dir: Path, + sheet_index: int, + safe_name: str, + dpi: int, + queue: mp.Queue[dict[str, list[str] | str]], +) -> None: + """Worker process to render PDF pages into PNG files.""" + try: + import pypdfium2 as pdfium + + scale = dpi / 72.0 + output_dir.mkdir(parents=True, exist_ok=True) + written: list[str] = [] + with pdfium.PdfDocument(str(pdf_path)) as pdf: + for page_index in range(len(pdf)): + page = pdf[page_index] + bitmap = page.render(scale=scale) + pil_image = bitmap.to_pil() + page_suffix = f"_p{page_index + 1:02d}" if page_index > 0 else "" + img_path = ( + output_dir / f"{sheet_index + 1:02d}_{safe_name}{page_suffix}.png" + ) + pil_image.save(img_path, format="PNG", dpi=(dpi, dpi)) + written.append(str(img_path)) + queue.put({"paths": written}) + except Exception as exc: + queue.put({"error": str(exc)}) + + __all__ = ["export_pdf", "export_sheet_images"] diff --git a/tests/render/test_render_init.py b/tests/render/test_render_init.py index 4dc0fc9..33dae29 100644 --- a/tests/render/test_render_init.py +++ b/tests/render/test_render_init.py @@ -258,6 +258,7 @@ def test_export_sheet_images_success( xlsx = tmp_path / "input.xlsx" xlsx.write_bytes(b"dummy") out_dir = tmp_path / "images" + monkeypatch.setenv("EXSTRUCT_RENDER_SUBPROCESS", "0") fake_pdfium = SimpleNamespace(PdfDocument=FakePdfDocument) monkeypatch.setattr(render, "_require_pdfium", lambda: fake_pdfium) @@ -280,6 +281,7 @@ def test_export_sheet_images_propagates_render_error( xlsx = tmp_path / "input.xlsx" xlsx.write_bytes(b"dummy") out_dir = tmp_path / "images" + monkeypatch.setenv("EXSTRUCT_RENDER_SUBPROCESS", "0") fake_pdfium = SimpleNamespace(PdfDocument=FakePdfDocument) monkeypatch.setattr(render, "_require_pdfium", lambda: fake_pdfium) @@ -298,6 +300,7 @@ def test_export_sheet_images_wraps_unknown_error( xlsx = tmp_path / "input.xlsx" xlsx.write_bytes(b"dummy") out_dir = tmp_path / "images" + monkeypatch.setenv("EXSTRUCT_RENDER_SUBPROCESS", "0") class ExplodingPdfDocument: """PdfDocument stub that raises on enter.""" From d6c66ec7eadda8a074bfd46dd721cecdee1b9f3c Mon Sep 17 00:00:00 2001 From: harumiWeb Date: Mon, 12 Jan 2026 16:21:56 +0900 Subject: [PATCH 07/12] =?UTF-8?q?=E3=82=B5=E3=83=96=E3=83=97=E3=83=AD?= =?UTF-8?q?=E3=82=BB=E3=82=B9=E5=88=86=E5=B2=90=E3=81=A8=E7=92=B0=E5=A2=83?= =?UTF-8?q?=E5=A4=89=E6=95=B0=E3=83=88=E3=82=B0=E3=83=AB=E3=81=AE=E3=83=86?= =?UTF-8?q?=E3=82=B9=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/agents/TASKS.md | 8 +++--- tests/render/test_render_init.py | 42 ++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/docs/agents/TASKS.md b/docs/agents/TASKS.md index 495bd81..742e3b4 100644 --- a/docs/agents/TASKS.md +++ b/docs/agents/TASKS.md @@ -2,7 +2,7 @@ 未完了 [ ], 完了 [x] -- [ ] 仕様確認: 画像出力は DPI を維持しつつ、メモリリーク/クラッシュ回避のためサブプロセス化で処理する方針を明記 -- [ ] 実装方針: シートごとに PDF を分割 → サブプロセスで PDF ページを PNG へ変換 → 終了時にメモリを解放する設計(親は進捗/結果を集約) -- [ ] 実装方針: 子プロセスは `pypdfium2` をロードしてページごとにレンダリングし、書き込み済みパスを親に返す -- [ ] 実装方針: 例外時は子プロセスでエラーを返し、親が RenderError として集約して返す +- [x] 仕様確認: 画像出力は DPI を維持しつつ、メモリリーク/クラッシュ回避のためサブプロセス化で処理する方針を明記 +- [x] 実装方針: シートごとに PDF を分割 → サブプロセスで PDF ページを PNG へ変換 → 終了時にメモリを解放する設計(親は進捗/結果を集約) +- [x] 実装方針: 子プロセスは `pypdfium2` をロードしてページごとにレンダリングし、書き込み済みパスを親に返す +- [x] 実装方針: 例外時は子プロセスでエラーを返し、親が RenderError として集約して返す diff --git a/tests/render/test_render_init.py b/tests/render/test_render_init.py index 33dae29..d033f13 100644 --- a/tests/render/test_render_init.py +++ b/tests/render/test_render_init.py @@ -332,6 +332,48 @@ def __exit__( render.export_sheet_images(xlsx, out_dir) +def test_export_sheet_images_uses_subprocess_when_enabled( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """export_sheet_images delegates to subprocess rendering when enabled.""" + xlsx = tmp_path / "input.xlsx" + xlsx.write_bytes(b"dummy") + out_dir = tmp_path / "images" + + calls: list[tuple[Path, Path, int, str, int]] = [] + + def _fake_subprocess( + pdf_path: Path, + output_dir: Path, + sheet_index: int, + safe_name: str, + dpi: int, + ) -> list[Path]: + calls.append((pdf_path, output_dir, sheet_index, safe_name, dpi)) + return [output_dir / f"{sheet_index + 1:02d}_{safe_name}.png"] + + monkeypatch.setenv("EXSTRUCT_RENDER_SUBPROCESS", "1") + monkeypatch.setattr( + render, "_require_excel_app", lambda: FakeApp(["SheetA", "SheetB"], False) + ) + monkeypatch.setattr(render, "_require_pdfium", lambda: SimpleNamespace()) + monkeypatch.setattr(render, "_render_pdf_pages_subprocess", _fake_subprocess) + + written = render.export_sheet_images(xlsx, out_dir, dpi=144) + + assert len(calls) == 2 + assert written[0].name == "01_SheetA.png" + assert written[1].name == "02_SheetB.png" + + +def test_use_render_subprocess_env_toggle(monkeypatch: pytest.MonkeyPatch) -> None: + """_use_render_subprocess respects the env toggle.""" + monkeypatch.setenv("EXSTRUCT_RENDER_SUBPROCESS", "1") + assert render._use_render_subprocess() is True + monkeypatch.setenv("EXSTRUCT_RENDER_SUBPROCESS", "0") + assert render._use_render_subprocess() is False + + def test_sanitize_sheet_filename() -> None: """_sanitize_sheet_filename replaces invalid characters and defaults.""" assert render._sanitize_sheet_filename("Sheet/1") == "Sheet_1" From 58bdd9ee33e07b0cea420e31d8579248831d9359 Mon Sep 17 00:00:00 2001 From: harumiWeb Date: Mon, 12 Jan 2026 16:32:44 +0900 Subject: [PATCH 08/12] =?UTF-8?q?=5Frender=5Fpdf=5Fpages=5Fsubprocess=20?= =?UTF-8?q?=E3=81=AE=E6=88=90=E5=8A=9F/=E5=A4=B1=E6=95=97=E3=80=81=5Frende?= =?UTF-8?q?r=5Fpdf=5Fpages=5Fworker=20=E3=81=AE=E6=88=90=E5=8A=9F/?= =?UTF-8?q?=E5=A4=B1=E6=95=97=E3=82=92=E3=81=9D=E3=82=8C=E3=81=9E=E3=82=8C?= =?UTF-8?q?=E6=A4=9C=E8=A8=BC=E3=81=99=E3=82=8B=E3=82=B1=E3=83=BC=E3=82=B9?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/render/test_render_init.py | 189 +++++++++++++++++++++++++++---- 1 file changed, 168 insertions(+), 21 deletions(-) diff --git a/tests/render/test_render_init.py b/tests/render/test_render_init.py index d033f13..bfb8719 100644 --- a/tests/render/test_render_init.py +++ b/tests/render/test_render_init.py @@ -4,7 +4,9 @@ from collections.abc import Callable from pathlib import Path import shutil -from types import SimpleNamespace +import sys +from types import ModuleType, SimpleNamespace +from typing import Any, cast import pytest import xlwings as xw @@ -126,6 +128,27 @@ def __len__(self) -> int: return self._page_count +class ExplodingPdfDocument: + """PdfDocument stub that raises on enter.""" + + def __init__(self, path: str) -> None: + _ = path + + def __enter__(self) -> ExplodingPdfDocument: + raise RuntimeError("boom") + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: object | None, + ) -> bool | None: + _ = exc_type + _ = exc + _ = tb + return None + + class FakeImage: """Stub of a PIL image with a save method.""" @@ -302,26 +325,6 @@ def test_export_sheet_images_wraps_unknown_error( out_dir = tmp_path / "images" monkeypatch.setenv("EXSTRUCT_RENDER_SUBPROCESS", "0") - class ExplodingPdfDocument: - """PdfDocument stub that raises on enter.""" - - def __init__(self, path: str) -> None: - _ = path - - def __enter__(self) -> ExplodingPdfDocument: - raise RuntimeError("boom") - - def __exit__( - self, - exc_type: type[BaseException] | None, - exc: BaseException | None, - tb: object | None, - ) -> bool | None: - _ = exc_type - _ = exc - _ = tb - return None - fake_pdfium = SimpleNamespace(PdfDocument=ExplodingPdfDocument) monkeypatch.setattr(render, "_require_pdfium", lambda: fake_pdfium) monkeypatch.setattr( @@ -374,6 +377,150 @@ def test_use_render_subprocess_env_toggle(monkeypatch: pytest.MonkeyPatch) -> No assert render._use_render_subprocess() is False +class FakeQueue: + """Stub queue for subprocess tests.""" + + def __init__(self) -> None: + self.payload: dict[str, list[str] | str] | None = None + + def put(self, payload: dict[str, list[str] | str]) -> None: + self.payload = payload + + def get(self) -> dict[str, list[str] | str]: + if self.payload is None: + return {"error": "no payload"} + return self.payload + + def empty(self) -> bool: + return self.payload is None + + +class FakeProcess: + """Stub process for subprocess tests.""" + + def __init__( + self, + queue: FakeQueue, + exitcode: int, + payload: dict[str, list[str] | str] | None = None, + ) -> None: + self._queue = queue + self.exitcode = exitcode + if payload is not None: + self._queue.put(payload) + + def start(self) -> None: + if self._queue.payload is None: + self._queue.put({"paths": ["dummy"]}) + + def join(self) -> None: + return None + + +class FakeContext: + """Stub multiprocessing context for subprocess tests.""" + + def __init__(self, queue: FakeQueue, process: FakeProcess) -> None: + self._queue = queue + self._process = process + + def Queue(self) -> FakeQueue: + return self._queue + + def Process(self, target: object, args: tuple[object, ...]) -> FakeProcess: + _ = target + _ = args + return self._process + + +def test_render_pdf_pages_subprocess_success(tmp_path: Path) -> None: + """_render_pdf_pages_subprocess returns paths when worker succeeds.""" + queue = FakeQueue() + process = FakeProcess( + queue, + exitcode=0, + payload={"paths": [str(tmp_path / "images" / "01_Sheet1.png")]}, + ) + context = FakeContext(queue, process) + + def _get_context(_: str) -> FakeContext: + return context + + pdf_path = tmp_path / "sheet_01.pdf" + pdf_path.write_bytes(b"%PDF-1.4") + output_dir = tmp_path / "images" + + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr(render.mp, "get_context", _get_context) + result = render._render_pdf_pages_subprocess( + pdf_path, output_dir, 0, "Sheet1", 144 + ) + + assert result == [output_dir / "01_Sheet1.png"] + + +def test_render_pdf_pages_subprocess_error(tmp_path: Path) -> None: + """_render_pdf_pages_subprocess raises when worker reports error.""" + queue = FakeQueue() + process = FakeProcess(queue, exitcode=0, payload={"error": "boom"}) + context = FakeContext(queue, process) + + def _get_context(_: str) -> FakeContext: + return context + + pdf_path = tmp_path / "sheet_01.pdf" + pdf_path.write_bytes(b"%PDF-1.4") + output_dir = tmp_path / "images" + + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr(render.mp, "get_context", _get_context) + with pytest.raises(RenderError, match="boom"): + render._render_pdf_pages_subprocess(pdf_path, output_dir, 0, "Sheet1", 144) + + +def test_render_pdf_pages_worker_success(tmp_path: Path) -> None: + """_render_pdf_pages_worker writes images and returns paths.""" + pdf_path = tmp_path / "sheet_01.pdf" + pdf_path.write_bytes(b"%PDF-1.4") + output_dir = tmp_path / "images" + queue = FakeQueue() + fake_pdfium = cast(Any, ModuleType("pypdfium2")) + fake_pdfium.PdfDocument = FakePdfDocument + + sys.modules["pypdfium2"] = fake_pdfium + try: + render._render_pdf_pages_worker(pdf_path, output_dir, 0, "Sheet1", 144, queue) + finally: + sys.modules.pop("pypdfium2", None) + + assert queue.payload == { + "paths": [ + str(output_dir / "01_Sheet1.png"), + str(output_dir / "01_Sheet1_p02.png"), + ] + } + assert (output_dir / "01_Sheet1.png").exists() + assert (output_dir / "01_Sheet1_p02.png").exists() + + +def test_render_pdf_pages_worker_error(tmp_path: Path) -> None: + """_render_pdf_pages_worker reports errors via queue.""" + pdf_path = tmp_path / "sheet_01.pdf" + pdf_path.write_bytes(b"%PDF-1.4") + output_dir = tmp_path / "images" + queue = FakeQueue() + + fake_pdfium = cast(Any, ModuleType("pypdfium2")) + fake_pdfium.PdfDocument = ExplodingPdfDocument + sys.modules["pypdfium2"] = fake_pdfium + try: + render._render_pdf_pages_worker(pdf_path, output_dir, 0, "Sheet1", 144, queue) + finally: + sys.modules.pop("pypdfium2", None) + + assert queue.payload == {"error": "boom"} + + def test_sanitize_sheet_filename() -> None: """_sanitize_sheet_filename replaces invalid characters and defaults.""" assert render._sanitize_sheet_filename("Sheet/1") == "Sheet_1" From f1d5c3d16a169e8e2b08ec26fd7e4d9d9eed1172 Mon Sep 17 00:00:00 2001 From: harumiWeb Date: Mon, 12 Jan 2026 16:37:26 +0900 Subject: [PATCH 09/12] =?UTF-8?q?=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E6=8C=87=E6=91=98=E3=81=AB=E5=AF=BE=E5=BF=9C=E3=81=97=E3=81=A6?= =?UTF-8?q?=E3=80=81Queue.empty()=20=E3=82=92=E4=BD=BF=E3=82=8F=E3=81=9A?= =?UTF-8?q?=20queue.get(timeout=3D...)=20=E3=82=92=E4=BD=BF=E3=81=86?= =?UTF-8?q?=E5=AE=9F=E8=A3=85=E3=81=AB=E6=9B=B4=E6=96=B0=E3=81=97=E3=80=81?= =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=82=82=E3=82=BF=E3=82=A4=E3=83=A0?= =?UTF-8?q?=E3=82=A2=E3=82=A6=E3=83=88=E7=B5=8C=E8=B7=AF=E3=82=92=E3=82=AB?= =?UTF-8?q?=E3=83=90=E3=83=BC=E3=81=99=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/exstruct/render/__init__.py | 15 +++++++++++---- tests/render/test_render_init.py | 28 ++++++++++++++++++++++------ 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/exstruct/render/__init__.py b/src/exstruct/render/__init__.py index 4573cbf..9c76481 100644 --- a/src/exstruct/render/__init__.py +++ b/src/exstruct/render/__init__.py @@ -187,10 +187,7 @@ def _render_pdf_pages_subprocess( ) process.start() process.join() - if not queue.empty(): - result = queue.get() - else: - result = {"error": "subprocess did not return results"} + result = _get_subprocess_result(queue) if process.exitcode != 0 or "error" in result: message = result.get("error", "subprocess failed") raise RenderError(f"Failed to render PDF pages: {message}") @@ -198,6 +195,16 @@ def _render_pdf_pages_subprocess( return [Path(path) for path in paths] +def _get_subprocess_result( + queue: mp.Queue[dict[str, list[str] | str]], +) -> dict[str, list[str] | str]: + """Fetch the worker result from the queue with a timeout.""" + try: + return queue.get(timeout=5) + except Exception as exc: + return {"error": f"subprocess did not return results ({exc})"} + + def _render_pdf_pages_worker( pdf_path: Path, output_dir: Path, diff --git a/tests/render/test_render_init.py b/tests/render/test_render_init.py index bfb8719..d9b7134 100644 --- a/tests/render/test_render_init.py +++ b/tests/render/test_render_init.py @@ -386,9 +386,10 @@ def __init__(self) -> None: def put(self, payload: dict[str, list[str] | str]) -> None: self.payload = payload - def get(self) -> dict[str, list[str] | str]: + def get(self, timeout: float | None = None) -> dict[str, list[str] | str]: + _ = timeout if self.payload is None: - return {"error": "no payload"} + raise TimeoutError("timeout") return self.payload def empty(self) -> bool: @@ -442,6 +443,7 @@ def test_render_pdf_pages_subprocess_success(tmp_path: Path) -> None: payload={"paths": [str(tmp_path / "images" / "01_Sheet1.png")]}, ) context = FakeContext(queue, process) + render_mp = cast(Any, render).mp def _get_context(_: str) -> FakeContext: return context @@ -451,7 +453,7 @@ def _get_context(_: str) -> FakeContext: output_dir = tmp_path / "images" with pytest.MonkeyPatch.context() as monkeypatch: - monkeypatch.setattr(render.mp, "get_context", _get_context) + monkeypatch.setattr(render_mp, "get_context", _get_context) result = render._render_pdf_pages_subprocess( pdf_path, output_dir, 0, "Sheet1", 144 ) @@ -464,6 +466,7 @@ def test_render_pdf_pages_subprocess_error(tmp_path: Path) -> None: queue = FakeQueue() process = FakeProcess(queue, exitcode=0, payload={"error": "boom"}) context = FakeContext(queue, process) + render_mp = cast(Any, render).mp def _get_context(_: str) -> FakeContext: return context @@ -473,11 +476,20 @@ def _get_context(_: str) -> FakeContext: output_dir = tmp_path / "images" with pytest.MonkeyPatch.context() as monkeypatch: - monkeypatch.setattr(render.mp, "get_context", _get_context) + monkeypatch.setattr(render_mp, "get_context", _get_context) with pytest.raises(RenderError, match="boom"): render._render_pdf_pages_subprocess(pdf_path, output_dir, 0, "Sheet1", 144) +def test_get_subprocess_result_timeout() -> None: + """_get_subprocess_result returns an error payload on timeout.""" + queue = FakeQueue() + result = render._get_subprocess_result(cast(Any, queue)) + + error = cast(str, result["error"]) + assert error.startswith("subprocess did not return results") + + def test_render_pdf_pages_worker_success(tmp_path: Path) -> None: """_render_pdf_pages_worker writes images and returns paths.""" pdf_path = tmp_path / "sheet_01.pdf" @@ -489,7 +501,9 @@ def test_render_pdf_pages_worker_success(tmp_path: Path) -> None: sys.modules["pypdfium2"] = fake_pdfium try: - render._render_pdf_pages_worker(pdf_path, output_dir, 0, "Sheet1", 144, queue) + render._render_pdf_pages_worker( + pdf_path, output_dir, 0, "Sheet1", 144, cast(Any, queue) + ) finally: sys.modules.pop("pypdfium2", None) @@ -514,7 +528,9 @@ def test_render_pdf_pages_worker_error(tmp_path: Path) -> None: fake_pdfium.PdfDocument = ExplodingPdfDocument sys.modules["pypdfium2"] = fake_pdfium try: - render._render_pdf_pages_worker(pdf_path, output_dir, 0, "Sheet1", 144, queue) + render._render_pdf_pages_worker( + pdf_path, output_dir, 0, "Sheet1", 144, cast(Any, queue) + ) finally: sys.modules.pop("pypdfium2", None) From c30482e24767d72c2c9395ea4b5a350b95ce5523 Mon Sep 17 00:00:00 2001 From: harumiWeb Date: Mon, 12 Jan 2026 16:38:59 +0900 Subject: [PATCH 10/12] =?UTF-8?q?=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E6=8C=87=E6=91=98=E3=81=AB=E5=AF=BE=E5=BF=9C=E3=81=97=E3=80=81?= =?UTF-8?q?=E6=9E=9A=E6=95=B0=E3=81=A7=E3=81=AF=E3=81=AA=E3=81=8F=E3=82=B7?= =?UTF-8?q?=E3=83=BC=E3=83=88=E6=8E=A5=E9=A0=AD=E8=BE=9E=E3=81=AE=E3=83=A6?= =?UTF-8?q?=E3=83=8B=E3=83=BC=E3=82=AF=E6=95=B0=E3=81=A7=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E3=81=99=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB=E5=A4=89=E6=9B=B4?= =?UTF-8?q?=E3=81=97=E3=81=BE=E3=81=97=E3=81=9F=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/com/test_render_smoke.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/com/test_render_smoke.py b/tests/com/test_render_smoke.py index d85ec52..fb4a38f 100644 --- a/tests/com/test_render_smoke.py +++ b/tests/com/test_render_smoke.py @@ -55,4 +55,15 @@ def test_render_multiple_print_ranges_images(tmp_path: Path) -> None: images_dir = out_json.parent / f"{out_json.stem}_images" images = list(images_dir.glob("*.png")) assert images_dir.exists() - assert len(images) == 4 + prefixes = {_strip_page_suffix(image.stem) for image in images} + assert len(prefixes) == 4 + + +def _strip_page_suffix(stem: str) -> str: + """Return the image stem without the _pNN page suffix.""" + if "_p" not in stem: + return stem + base, suffix = stem.rsplit("_p", 1) + if len(suffix) == 2 and suffix.isdigit(): + return base + return stem From c5ae81122f4ecb584dcee375abe0cbce80d65107 Mon Sep 17 00:00:00 2001 From: harumiWeb Date: Mon, 12 Jan 2026 16:39:32 +0900 Subject: [PATCH 11/12] update --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 731ae9a..7b6fe5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "exstruct" -version = "0.3.5" +version = "0.3.6" description = "Excel to structured JSON (tables, shapes, charts) for LLM/RAG pipelines" readme = "README.md" license = { file = "LICENSE" } From 014e99cf0b316d9023c3d4380a8869d0cdbc2238 Mon Sep 17 00:00:00 2001 From: harumiWeb Date: Mon, 12 Jan 2026 16:44:01 +0900 Subject: [PATCH 12/12] =?UTF-8?q?=E3=83=AA=E3=83=AA=E3=83=BC=E3=82=B9?= =?UTF-8?q?=E3=83=8E=E3=83=BC=E3=83=88=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/release-notes/v0.3.6.md | 17 +++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 18 insertions(+) create mode 100644 docs/release-notes/v0.3.6.md diff --git a/docs/release-notes/v0.3.6.md b/docs/release-notes/v0.3.6.md new file mode 100644 index 0000000..913d0ab --- /dev/null +++ b/docs/release-notes/v0.3.6.md @@ -0,0 +1,17 @@ +# v0.3.6 Release Notes + +This release improves rendering robustness for image export and large Excel +files, with better support for multi-page sheets and legacy .xls inputs. + +## Highlights + +- Sheet image export now renders all PDF pages per sheet, with `_pNN` suffixes + for page 2+ (fixes multi-print-range sheets outputting only the first image). +- .xls rendering now uses Excel SaveAs to a temporary .xlsx before PDF export, + avoiding failures when outputting images from legacy files. +- Image rendering can run in a subprocess to isolate memory usage and reduce + crashes on large workbooks (enabled by default). + +## Notes + +- Set `EXSTRUCT_RENDER_SUBPROCESS=0` to disable subprocess rendering. diff --git a/mkdocs.yml b/mkdocs.yml index 126d2dc..33a0b0f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -27,6 +27,7 @@ nav: - CLI Guide: cli.md - Concept / Why ExStruct?: concept.md - Release Notes: + - v0.3.6: release-notes/v0.3.6.md - v0.3.5: release-notes/v0.3.5.md - v0.3.2: release-notes/v0.3.2.md - v0.3.1: release-notes/v0.3.1.md