Skip to content

Commit f90695c

Browse files
committed
feat: bump version to 0.7.3, add support for image and chart copying in openpyxl engine
Update the version to 0.7.3 and enhance the OpenpyxlEngine to support copying images and charts between worksheets. Introduce new methods for image and chart handling, ensuring that visual elements are preserved during sheet imports. Add comprehensive tests to validate the functionality of image and chart preservation, including end-to-end tests for various scenarios.
1 parent 4bd8893 commit f90695c

File tree

6 files changed

+786
-2
lines changed

6 files changed

+786
-2
lines changed

AGENTS.md

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# AGENTS.md (repo instructions for coding agents)
2+
3+
This repository is `xpyxl`, a typed Python 3.12 library for building and rendering Excel (and HTML) reports.
4+
5+
If you are an agent making changes:
6+
- Keep diffs small and focused.
7+
- Prefer the existing patterns in `src/xpyxl/` and tests in `tests/`.
8+
- Do not add new tooling/deps unless requested.
9+
10+
## Repo layout
11+
- `src/xpyxl/`: library code (public API re-exported from `src/xpyxl/__init__.py`).
12+
- `src/xpyxl/engines/`: rendering backends.
13+
- `tests/`: pytest suite.
14+
- `tests/end_to_end/`: demo modules and an end-to-end test that saves files into `.testing/`.
15+
- `scripts/`: ad-hoc scripts (e.g. benchmarking).
16+
17+
## Environment
18+
- Python: `3.12` (see `.python-version` and CI workflow).
19+
- Dependency manager/build tool: `uv` (see `uv.lock` and `.github/workflows/publish.yml`).
20+
21+
## Commands (build / lint / test)
22+
23+
### Install / sync
24+
- Create/sync a dev environment: `uv sync --dev`
25+
- Run a one-off command in the env: `uv run <cmd>`
26+
27+
Notes:
28+
- `uv.lock` exists; prefer workflows that respect the lock file for reproducibility.
29+
- If you add/remove dependencies, update `pyproject.toml` and refresh `uv.lock`.
30+
31+
### Tests (pytest)
32+
- Run all tests: `uv run pytest`
33+
- Run tests with output: `uv run pytest -q` or `uv run pytest -vv`
34+
- Stop on first failure: `uv run pytest -x --maxfail=1`
35+
36+
Run a single file:
37+
- `uv run pytest tests/test_html_engine.py`
38+
39+
Run a single test (node id):
40+
- `uv run pytest tests/test_html_engine.py::test_tailwind_cdn_included`
41+
42+
Run tests matching a substring/expr:
43+
- `uv run pytest -k "html and not missing"`
44+
45+
Run only end-to-end test:
46+
- `uv run pytest tests/end_to_end/test_demo.py`
47+
48+
### Type checking (lint-ish)
49+
There is no dedicated linter configured in this repo, but type checking is part of the dev toolchain.
50+
- Run Pyright: `uv run pyright`
51+
52+
### Build
53+
CI builds with `uv build`.
54+
- Build sdist + wheel: `uv build --sdist --wheel`
55+
56+
### Misc
57+
- Benchmark script: `uv run python scripts/benchmark.py`
58+
59+
### Quick sanity (optional)
60+
These are useful when iterating on core rendering/layout changes:
61+
- Import check: `uv run python -c "import xpyxl"`
62+
- Bytecode compile: `uv run python -m compileall src/xpyxl`
63+
- Minimal save smoke test: `uv run python -c "import xpyxl as x; wb=x.workbook()[x.sheet('S')[x.row()['ok']]]; wb.save(engine='html')"`
64+
65+
### CI parity
66+
Before opening a PR, prefer running:
67+
- `uv run pyright`
68+
- `uv run pytest`
69+
70+
## Cursor/Copilot rules
71+
- No Cursor rules found in `.cursor/rules/` or `.cursorrules` at time of writing.
72+
- No Copilot instructions found in `.github/copilot-instructions.md` at time of writing.
73+
74+
If these files appear later, follow them and update this document accordingly.
75+
76+
## Code style (Python)
77+
78+
### General
79+
- Target Python `>=3.12`.
80+
- Prefer small, composable functions over deeply nested logic.
81+
- Favor determinism: avoid randomness/time dependence in core logic unless explicitly required.
82+
83+
### Imports
84+
Follow the existing import ordering:
85+
1. `from __future__ import annotations` (used widely; include in new modules).
86+
2. Standard library imports.
87+
3. Third-party imports.
88+
4. Local imports (`from .foo import Bar`).
89+
90+
Prefer:
91+
- `collections.abc` for `Sequence`, `Mapping`, etc.
92+
- `pathlib.Path` for filesystem paths.
93+
94+
### Formatting
95+
- No formatter is enforced by configuration files in this repo.
96+
- Keep formatting consistent with the current codebase (Black-compatible style is already used: trailing commas, hanging indents, one-arg-per-line when wrapping).
97+
- Keep line length reasonable (the current code is typically ~88-ish, but follow surrounding code).
98+
99+
### Types
100+
- The codebase is fully typed; keep/add type hints for new/changed public functions.
101+
- Prefer modern syntax:
102+
- `X | None` instead of `Optional[X]`.
103+
- `list[str]`, `tuple[int, ...]`, etc.
104+
- `TypeAlias` for aliases (see `src/xpyxl/nodes.py`).
105+
- Use `assert_never(...)` for exhaustiveness in tagged unions when appropriate.
106+
- Avoid `Any` unless it is truly necessary at API boundaries.
107+
108+
### Naming
109+
- Modules: `snake_case.py`.
110+
- Classes: `CamelCase`.
111+
- Functions/vars: `snake_case`.
112+
- Constants: `UPPER_SNAKE_CASE`.
113+
- Private helpers: prefix `_`.
114+
115+
### Data model & immutability
116+
- Core AST-like nodes are immutable dataclasses (`@dataclass(frozen=True)`).
117+
- Prefer returning new values over mutating existing nodes.
118+
119+
### Errors and validation
120+
- Use `TypeError` for incorrect types/shape (e.g. passing a `RowNode` where a scalar cell is expected).
121+
- Use `ValueError` for invalid values/ranges (e.g. negative sizes).
122+
- Error messages should be actionable and stable (tests may match them).
123+
- Prefer:
124+
- `msg = "..."; raise ValueError(msg)` when the message is reused or multi-line.
125+
- `raise ValueError("...")` for simple one-liners.
126+
127+
### Public API surface
128+
- Public API is re-exported in `src/xpyxl/__init__.py`.
129+
- If you add a new user-facing function/type, consider whether it must be exported and added to `__all__`.
130+
- Keep backwards compatibility in mind: avoid renames/breaking changes unless requested.
131+
132+
### Performance
133+
- Rendering can be performance-sensitive. Avoid introducing quadratic behavior in layout/render loops.
134+
- Prefer tuples for immutable collections exposed on nodes (consistent with current dataclasses).
135+
136+
## Tests
137+
- Tests use `pytest`.
138+
- Prefer unit tests in `tests/` and keep them deterministic.
139+
- For exception behavior, use `pytest.raises(..., match=...)` with a stable substring/regex.
140+
- The end-to-end test writes to `.testing/` (ignored by git); do not commit generated output files.
141+
142+
### Test file I/O
143+
- Prefer `tempfile.TemporaryDirectory()` for tests that write outputs.
144+
- If you need stable artifacts for manual inspection, write under `.testing/`.
145+
- Keep file names deterministic to avoid flaky diffs.
146+
147+
## Git hygiene
148+
- Do not commit local artifacts: `__pycache__/`, `.pytest_cache/`, `.testing/`, `*.egg-info/`.
149+
- Keep `uv.lock` in sync with `pyproject.toml` when dependencies change.

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "xpyxl"
3-
version = "0.7.2"
3+
version = "0.7.3"
44
description = "Create styled excel reports with declarative python."
55
readme = "README.md"
66
authors = [{ name = "dakixr", email = "[email protected]" }]
@@ -9,6 +9,7 @@ dependencies = ["openpyxl>=3.1.5", "xlsxwriter>=3.1.0"]
99

1010
[dependency-groups]
1111
dev = [
12+
"pillow>=12.1.0",
1213
"pyright>=1.1.406",
1314
"pytest>=9.0.2",
1415
]

src/xpyxl/engines/openpyxl_engine.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,138 @@ def _copy_dimension_attrs(source_dim: object, dest_dim: object) -> None:
390390
for row, dimension in source_ws.row_dimensions.items():
391391
_copy_dimension_attrs(dimension, target_ws.row_dimensions[row])
392392

393+
# Images (photos/pictures)
394+
self._copy_images(source_ws, target_ws)
395+
396+
# Charts
397+
self._copy_charts(source_ws, target_ws)
398+
399+
def _copy_images(self, source_ws: Worksheet, target_ws: Worksheet) -> None:
400+
"""Copy all images from source worksheet to target worksheet."""
401+
try:
402+
from openpyxl.drawing.image import Image
403+
except ImportError:
404+
# Image support not available
405+
return
406+
407+
for img in getattr(source_ws, "_images", []):
408+
try:
409+
# Get the image data from the ref (BytesIO containing the image data)
410+
ref = getattr(img, "ref", None)
411+
if ref is None:
412+
continue
413+
414+
# Seek to start if possible
415+
if hasattr(ref, "seek"):
416+
try:
417+
ref.seek(0)
418+
except Exception:
419+
pass
420+
421+
# Create a new Image instance from the image data
422+
new_img = Image(ref)
423+
424+
# Copy anchor (position in the sheet)
425+
anchor = getattr(img, "anchor", None)
426+
if anchor is not None:
427+
new_img.anchor = anchor
428+
429+
# Copy dimensions if set
430+
if getattr(img, "width", None) is not None:
431+
new_img.width = img.width
432+
if getattr(img, "height", None) is not None:
433+
new_img.height = img.height
434+
435+
target_ws.add_image(new_img)
436+
except Exception:
437+
# Image copying is best-effort; skip failures
438+
pass
439+
440+
def _copy_charts(self, source_ws: Worksheet, target_ws: Worksheet) -> None:
441+
"""Copy all charts from source worksheet to target worksheet.
442+
443+
When copying charts, we need to update the data references to point
444+
to the target sheet instead of the source sheet, since sheet names
445+
may differ between source and destination.
446+
"""
447+
source_name = source_ws.title
448+
target_name = target_ws.title
449+
450+
for chart in getattr(source_ws, "_charts", []):
451+
try:
452+
# Deep copy the chart to avoid sharing references
453+
new_chart = copy.deepcopy(chart)
454+
455+
# Update sheet references in chart data
456+
self._update_chart_references(new_chart, source_name, target_name)
457+
458+
target_ws.add_chart(new_chart)
459+
except Exception:
460+
# Chart copying is best-effort; skip failures
461+
pass
462+
463+
def _update_chart_references(
464+
self, chart: object, old_sheet: str, new_sheet: str
465+
) -> None:
466+
"""Update all sheet references in a chart from old_sheet to new_sheet."""
467+
import re
468+
469+
def update_ref(formula: str | None) -> str | None:
470+
if not formula:
471+
return formula
472+
473+
# Escape special regex chars in old_sheet
474+
old_escaped = re.escape(old_sheet)
475+
476+
# Pattern for quoted sheet name: 'Sheet Name'!
477+
quoted_pattern = f"'{old_escaped}'!"
478+
# Pattern for unquoted sheet name: SheetName!
479+
unquoted_pattern = f"{old_escaped}!"
480+
481+
# New sheet name (quote if has spaces or special chars)
482+
if " " in new_sheet or any(c in new_sheet for c in "'![]:"):
483+
new_ref = f"'{new_sheet}'!"
484+
else:
485+
new_ref = f"{new_sheet}!"
486+
487+
# Replace quoted version first
488+
result = re.sub(quoted_pattern, new_ref, formula)
489+
# Then try unquoted version if no change
490+
if result == formula:
491+
result = re.sub(unquoted_pattern, new_ref, formula)
492+
493+
return result
494+
495+
# Update series data references
496+
for series in getattr(chart, "series", []):
497+
# Value reference (numRef)
498+
if hasattr(series, "val") and series.val:
499+
num_ref = getattr(series.val, "numRef", None)
500+
if num_ref and hasattr(num_ref, "f"):
501+
num_ref.f = update_ref(num_ref.f)
502+
503+
# Category reference (can be numRef or strRef)
504+
if hasattr(series, "cat") and series.cat:
505+
cat_num_ref = getattr(series.cat, "numRef", None)
506+
if cat_num_ref and hasattr(cat_num_ref, "f"):
507+
cat_num_ref.f = update_ref(cat_num_ref.f)
508+
509+
cat_str_ref = getattr(series.cat, "strRef", None)
510+
if cat_str_ref and hasattr(cat_str_ref, "f"):
511+
cat_str_ref.f = update_ref(cat_str_ref.f)
512+
513+
# X values (for scatter/bubble charts)
514+
if hasattr(series, "xVal") and series.xVal:
515+
x_num_ref = getattr(series.xVal, "numRef", None)
516+
if x_num_ref and hasattr(x_num_ref, "f"):
517+
x_num_ref.f = update_ref(x_num_ref.f)
518+
519+
# Y values (for scatter/bubble charts)
520+
if hasattr(series, "yVal") and series.yVal:
521+
y_num_ref = getattr(series.yVal, "numRef", None)
522+
if y_num_ref and hasattr(y_num_ref, "f"):
523+
y_num_ref.f = update_ref(y_num_ref.f)
524+
393525
def _load_source_workbook(self, source: SaveTarget | bytes | BinaryIO) -> Workbook:
394526
if isinstance(source, (str, Path)):
395527
return load_workbook(

0 commit comments

Comments
 (0)