ablechart 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ablechart/metadata.py ADDED
@@ -0,0 +1,198 @@
1
+ """Chart metadata persistence.
2
+
3
+ Belongs to: **metadata** lifecycle per ADR-0007 §1.
4
+ Realises: ADR-0004 §2 (metadata 5-piece round-trip contract)
5
+ + ADR-0007 §3 (layered metadata strategy).
6
+
7
+ Single entrypoint for chart-level semantic metadata. Today only **layer 1**
8
+ (embedded workbook hidden sheet) is implemented; the layered strategy goal
9
+ in ADR-0007 §3 is:
10
+
11
+ 1. embedded workbook hidden sheet ← layer 1, this module (default)
12
+ 2. chart semantic anchor / invisible shape
13
+ ← layer 2, lives in ``semantic_anchor.py`` for shape-composition
14
+ families that have no chart container
15
+ 3. custom XML part ← layer 3, requires PRD + compat check
16
+ 4. shape alt text / name ← layer 4, selector hint only
17
+
18
+ This module holds the **single source of truth** for ``METADATA_SHEET_NAME``
19
+ and ``METADATA_SCHEMA_VERSION``; previously these were duplicated in
20
+ ``api.py`` and ``parser.py``, a real silent-drift hazard.
21
+
22
+ Public:
23
+
24
+ - :data:`METADATA_SHEET_NAME`
25
+ - :data:`METADATA_SCHEMA_VERSION`
26
+ - :class:`ChartMetadataV1`
27
+ - :func:`write_chart_metadata`
28
+
29
+ Backward-compat (kept for existing callers):
30
+
31
+ - :func:`_write_embedded_metadata` — original signature preserved;
32
+ internally delegates to layer 1 backend.
33
+
34
+ ADR-0007 §2: this module avoids Pydantic; uses standard-library
35
+ ``dataclasses`` only.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import io
41
+ import json
42
+ from dataclasses import dataclass, field
43
+ from typing import Any, Dict, List, Optional
44
+
45
+ from ._log import debug_print as print
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Authoritative constants (DO NOT redefine in api.py / parser.py)
50
+ # ---------------------------------------------------------------------------
51
+
52
+ METADATA_SHEET_NAME: str = "_pptchartengine_meta"
53
+ """Name of the hidden sheet inside a chart's embedded workbook that holds
54
+ chart-level semantic metadata. Authoritative source; ``api.py`` and
55
+ ``parser.py`` re-import this rather than redefining."""
56
+
57
+ METADATA_SCHEMA_VERSION: str = "2"
58
+ """Current schema version written to ``B1`` of the metadata sheet.
59
+ Future schema changes must bump this AND maintain a backward-compat reader
60
+ in ``parser.py`` (ADR-0004 backward-compat clause)."""
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Public schema
65
+ # ---------------------------------------------------------------------------
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class ChartMetadataV1:
70
+ """Chart-level semantic metadata payload (ADR-0007 §3 layer 1, schema v2).
71
+
72
+ All fields are technical-layer only per ADR-0007 §2 — no business slot,
73
+ no ``user_id``, no prompt.
74
+ """
75
+
76
+ categories_col: str
77
+ series_config: List[Dict[str, Any]]
78
+ chart_family: Optional[str] = None
79
+ extra: Dict[str, Any] = field(default_factory=dict)
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Public API
84
+ # ---------------------------------------------------------------------------
85
+
86
+
87
+ def write_chart_metadata(chart, metadata: ChartMetadataV1) -> None:
88
+ """Persist :class:`ChartMetadataV1` into the chart's embedded workbook.
89
+
90
+ Routes through layer 1 (embedded workbook hidden sheet). Failure is
91
+ currently swallowed with a stderr-like log (preserves prior
92
+ ``_write_embedded_metadata`` semantics); a future schema version should
93
+ promote this to a typed :class:`ablechart.replace` style result or
94
+ raise a dedicated ``MetadataWriteError``.
95
+ """
96
+ _write_workbook_hidden_sheet(
97
+ chart=chart,
98
+ categories_col=metadata.categories_col,
99
+ series_config=metadata.series_config,
100
+ chart_family=metadata.chart_family,
101
+ extra=metadata.extra,
102
+ )
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Backward-compat (kept until callers migrate to write_chart_metadata)
107
+ # ---------------------------------------------------------------------------
108
+
109
+
110
+ def _write_embedded_metadata(
111
+ chart,
112
+ categories_col: str,
113
+ series_config: List[Dict],
114
+ metadata: Optional[Dict] = None,
115
+ ) -> None:
116
+ """Legacy signature preserved for ``api.py`` / ``scatter.py`` /
117
+ ``semantic_family.py`` call sites. Routes to layer 1 backend.
118
+
119
+ ``metadata`` here is a free-form dict that historically carried
120
+ ``chart_family`` plus arbitrary extra fields. We extract ``chart_family``
121
+ if present, treat the whole dict as ``extra``.
122
+ """
123
+ chart_family = None
124
+ if metadata is not None:
125
+ chart_family = metadata.get("chart_family")
126
+ _write_workbook_hidden_sheet(
127
+ chart=chart,
128
+ categories_col=categories_col,
129
+ series_config=series_config,
130
+ chart_family=chart_family,
131
+ extra=metadata,
132
+ )
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # Layer 1 backend — embedded workbook hidden sheet
137
+ # ---------------------------------------------------------------------------
138
+
139
+
140
+ def _write_workbook_hidden_sheet(
141
+ chart,
142
+ categories_col: str,
143
+ series_config: List[Dict[str, Any]],
144
+ chart_family: Optional[str] = None,
145
+ extra: Optional[Dict[str, Any]] = None,
146
+ ) -> None:
147
+ """Layer 1 implementation: write metadata into a hidden sheet inside
148
+ chart's embedded xlsx workbook. Preserves the exact byte layout of the
149
+ legacy ``_write_embedded_metadata`` so round-trip parsers stay happy."""
150
+ try:
151
+ from openpyxl import load_workbook
152
+
153
+ chart_part = chart.part
154
+ xlsx_part = chart_part.chart_workbook.xlsx_part
155
+ xlsx_stream = io.BytesIO(xlsx_part.blob)
156
+ wb = load_workbook(xlsx_stream)
157
+
158
+ if METADATA_SHEET_NAME in wb.sheetnames:
159
+ del wb[METADATA_SHEET_NAME]
160
+
161
+ ws = wb.create_sheet(METADATA_SHEET_NAME)
162
+ ws.sheet_state = "hidden"
163
+ ws["A1"] = "schema_version"
164
+ ws["B1"] = METADATA_SCHEMA_VERSION
165
+ ws["A2"] = "categories_col"
166
+ ws["B2"] = categories_col
167
+ ws["A3"] = "series_count"
168
+ ws["B3"] = len(series_config)
169
+ ws["A4"] = "chart_family"
170
+ ws["B4"] = chart_family
171
+ ws["A5"] = "chart_metadata_json"
172
+ # Legacy parity: the json blob is the full ``extra`` dict (which in
173
+ # practice already carries chart_family); we don't add a second copy.
174
+ ws["B5"] = json.dumps(extra, ensure_ascii=False) if extra else None
175
+ ws.append([])
176
+ ws.append([
177
+ "series_index", "key", "name", "type",
178
+ "axis", "grouping", "x_key", "size_key",
179
+ ])
180
+
181
+ for index, series in enumerate(series_config):
182
+ ws.append([
183
+ index,
184
+ series.get("key"),
185
+ series.get("name"),
186
+ series.get("type"),
187
+ series.get("axis"),
188
+ series.get("grouping"),
189
+ series.get("x_key"),
190
+ series.get("size_key"),
191
+ ])
192
+
193
+ output_stream = io.BytesIO()
194
+ wb.save(output_stream)
195
+ xlsx_part._blob = output_stream.getvalue()
196
+
197
+ except Exception as e:
198
+ print(f" ⚠️ 写入图表元数据失败: {e}")
@@ -0,0 +1,22 @@
1
+ """
2
+ OXML 包 - 底层 XML 操作
3
+
4
+ 这个包负责所有的 lxml 操作,不关心业务逻辑(主轴/次轴)。
5
+ 只知道如何根据指令创建标准的 OOXML 结构。
6
+ """
7
+
8
+ from .axes import extract_axis_ids, create_value_axis, optimize_axis_labels, optimize_category_axis
9
+ from .plots import create_plot_element, add_axis_refs, add_plot_categories
10
+ from .series import add_series_to_plot
11
+
12
+ __all__ = [
13
+ 'extract_axis_ids',
14
+ 'create_value_axis',
15
+ 'optimize_axis_labels',
16
+ 'optimize_category_axis',
17
+ 'create_plot_element',
18
+ 'add_axis_refs',
19
+ 'add_plot_categories',
20
+ 'add_series_to_plot',
21
+ ]
22
+
ablechart/oxml/axes.py ADDED
@@ -0,0 +1,219 @@
1
+ """
2
+ 坐标轴 XML 操作模块
3
+
4
+ 负责创建、提取和优化坐标轴元素。
5
+ """
6
+
7
+ from lxml import etree
8
+ from typing import Tuple
9
+
10
+ from .._log import debug_print as print
11
+ from ..oxml_ns import NAMESPACES
12
+
13
+
14
+ def extract_axis_ids(plotArea) -> Tuple[int, int]:
15
+ """
16
+ 提取现有坐标轴的 ID
17
+
18
+ Args:
19
+ plotArea: 绘图区元素 (lxml Element)
20
+
21
+ Returns:
22
+ (cat_ax_id, val_ax_id) 元组
23
+
24
+ Raises:
25
+ ValueError: 如果无法找到坐标轴 ID
26
+ """
27
+ # python-pptx 的 BaseOxmlElement.xpath() 已经注册了命名空间
28
+ cat_ax_elements = plotArea.xpath('.//c:catAx/c:axId')
29
+ val_ax_elements = plotArea.xpath('.//c:valAx/c:axId')
30
+
31
+ if cat_ax_elements and val_ax_elements:
32
+ cat_ax_id = int(cat_ax_elements[0].get('val'))
33
+ val_ax_id = int(val_ax_elements[0].get('val'))
34
+ return cat_ax_id, val_ax_id
35
+
36
+ if len(val_ax_elements) >= 2:
37
+ x_axis_id = int(val_ax_elements[0].get('val'))
38
+ y_axis_id = int(val_ax_elements[1].get('val'))
39
+ return x_axis_id, y_axis_id
40
+
41
+ raise ValueError("无法找到现有坐标轴 ID")
42
+
43
+
44
+ def create_value_axis(
45
+ plotArea,
46
+ ax_id: int,
47
+ cross_ax_id: int,
48
+ position: str = 'r',
49
+ tick_label_position: str = 'high',
50
+ crosses_at: str = 'max',
51
+ ) -> int:
52
+ """
53
+ 创建一个新的值轴 (Y轴)
54
+
55
+ Args:
56
+ plotArea: 绘图区元素 (lxml Element)
57
+ ax_id: 新轴的 ID
58
+ cross_ax_id: 交叉轴的 ID(通常是分类轴)
59
+ position: 轴位置 ('l'=左, 'r'=右, 't'=顶, 'b'=底)
60
+ tick_label_position: 标签位置 ('low'=左/底, 'high'=右/顶, 'nextTo'=靠近轴)
61
+ crosses_at: 交叉位置 ('min'=最小值/左边, 'max'=最大值/右边)
62
+
63
+ Returns:
64
+ 创建的轴 ID
65
+
66
+ Notes:
67
+ - 严格按照 OOXML 规范 (ISO/IEC 29500-1:2016) 的元素顺序
68
+ - 'low' 和 'high' 用于双轴图,确保标签不重叠
69
+ - crosses_at='min' 让轴线在图表左边,'max' 让轴线在图表右边
70
+ """
71
+ # 创建值轴元素
72
+ valAx = etree.SubElement(plotArea, f"{{{NAMESPACES['c']}}}valAx")
73
+
74
+ # ⭐ 严格按照 OOXML 规范顺序添加子元素
75
+
76
+ # 1. axId (轴 ID) - 必需
77
+ axId_elem = etree.SubElement(valAx, f"{{{NAMESPACES['c']}}}axId")
78
+ axId_elem.set('val', str(ax_id))
79
+
80
+ # 2. scaling (缩放) - 必需
81
+ scaling = etree.SubElement(valAx, f"{{{NAMESPACES['c']}}}scaling")
82
+ orientation = etree.SubElement(scaling, f"{{{NAMESPACES['c']}}}orientation")
83
+ orientation.set('val', 'minMax')
84
+
85
+ # 3. delete (是否隐藏) - 必需
86
+ delete = etree.SubElement(valAx, f"{{{NAMESPACES['c']}}}delete")
87
+ delete.set('val', '0')
88
+
89
+ # 4. axPos (轴位置) - 必需
90
+ axPos = etree.SubElement(valAx, f"{{{NAMESPACES['c']}}}axPos")
91
+ axPos.set('val', position)
92
+
93
+ # 5. majorGridlines (主网格线) - 可选
94
+ # 次轴通常不显示网格线,避免与主轴重叠
95
+ # 如果需要,调用方可以手动添加
96
+
97
+ # 6. numFmt (数字格式) - 可选
98
+ numFmt = etree.SubElement(valAx, f"{{{NAMESPACES['c']}}}numFmt")
99
+ numFmt.set('formatCode', 'General')
100
+ numFmt.set('sourceLinked', '0')
101
+
102
+ # 7. majorTickMark (主刻度线) - 可选
103
+ majorTickMark = etree.SubElement(valAx, f"{{{NAMESPACES['c']}}}majorTickMark")
104
+ majorTickMark.set('val', 'out')
105
+
106
+ # 8. minorTickMark (次刻度线) - 可选
107
+ minorTickMark = etree.SubElement(valAx, f"{{{NAMESPACES['c']}}}minorTickMark")
108
+ minorTickMark.set('val', 'none')
109
+
110
+ # 9. tickLblPos (标签位置) - 可选
111
+ tickLblPos = etree.SubElement(valAx, f"{{{NAMESPACES['c']}}}tickLblPos")
112
+ tickLblPos.set('val', tick_label_position)
113
+
114
+ # 10. crossAx (交叉轴 ID) - 必需
115
+ crossAx = etree.SubElement(valAx, f"{{{NAMESPACES['c']}}}crossAx")
116
+ crossAx.set('val', str(cross_ax_id))
117
+
118
+ # 11. crosses (交叉方式) - 可选
119
+ # ⭐ 关键修复:根据 crosses_at 参数决定交叉位置
120
+ # 'min' = 在最小值(左边)交叉,'max' = 在最大值(右边)交叉
121
+ crosses = etree.SubElement(valAx, f"{{{NAMESPACES['c']}}}crosses")
122
+ crosses.set('val', crosses_at)
123
+
124
+ # 12. crossBetween (交叉位置) - 可选
125
+ crossBetween = etree.SubElement(valAx, f"{{{NAMESPACES['c']}}}crossBetween")
126
+ crossBetween.set('val', 'between')
127
+
128
+ return ax_id
129
+
130
+
131
+ def optimize_axis_labels(
132
+ plotArea,
133
+ ax_id: int,
134
+ tick_label_position: str = 'low',
135
+ crosses_at: str = 'min',
136
+ remove_gridlines: bool = True,
137
+ ):
138
+ """
139
+ 优化现有轴的标签位置和交叉位置
140
+
141
+ Args:
142
+ plotArea: 绘图区元素
143
+ ax_id: 要优化的轴 ID
144
+ tick_label_position: 标签位置 ('low'=左/底, 'high'=右/顶)
145
+ crosses_at: 交叉位置 ('min'=最小值/左边, 'max'=最大值/右边)
146
+ remove_gridlines: 是否移除网格线(默认移除)
147
+
148
+ Notes:
149
+ 主要用于优化主值轴,使其与次值轴协调
150
+ crosses_at='min' 让主轴线在图表左边,'max' 让次轴线在图表右边
151
+ """
152
+ # 查找指定的值轴
153
+ val_ax_elements = plotArea.xpath(f'.//c:valAx[c:axId[@val="{ax_id}"]]')
154
+ if not val_ax_elements:
155
+ return # 轴不存在,跳过
156
+
157
+ val_ax = val_ax_elements[0]
158
+
159
+ # ⭐ 设置 crosses 位置
160
+ crosses_elements = val_ax.xpath('./c:crosses')
161
+ if crosses_elements:
162
+ crosses_elements[0].set('val', crosses_at)
163
+
164
+ # 设置标签位置
165
+ tickLblPos_elements = val_ax.xpath('./c:tickLblPos')
166
+ if tickLblPos_elements:
167
+ tickLblPos_elements[0].set('val', tick_label_position)
168
+ else:
169
+ # 如果不存在,创建一个
170
+ tickLblPos = etree.Element(f"{{{NAMESPACES['c']}}}tickLblPos")
171
+ tickLblPos.set('val', tick_label_position)
172
+ # 插入到 crossAx 之前(保持正确顺序)
173
+ cross_ax_elements = val_ax.xpath('./c:crossAx')
174
+ if cross_ax_elements:
175
+ cross_ax_elements[0].addprevious(tickLblPos)
176
+
177
+ # ⭐ 移除网格线(取消内部横框)
178
+ if remove_gridlines:
179
+ gridlines = val_ax.xpath('./c:majorGridlines')
180
+ for gridline in gridlines:
181
+ val_ax.remove(gridline)
182
+ print(f" → 已移除主值轴网格线")
183
+
184
+
185
+ def optimize_category_axis(
186
+ plotArea,
187
+ cat_ax_id: int,
188
+ remove_tick_marks: bool = True,
189
+ ):
190
+ """
191
+ 优化分类轴(X轴)的显示
192
+
193
+ Args:
194
+ plotArea: 绘图区元素
195
+ cat_ax_id: 分类轴 ID
196
+ remove_tick_marks: 是否移除主刻度线(默认移除,即取消日期间的小竖线)
197
+
198
+ Notes:
199
+ 用于清理分类轴的视觉元素,让图表更简洁
200
+ """
201
+ # 查找分类轴
202
+ cat_ax_elements = plotArea.xpath(f'.//c:catAx[c:axId[@val="{cat_ax_id}"]]')
203
+ if not cat_ax_elements:
204
+ return # 轴不存在,跳过
205
+
206
+ cat_ax = cat_ax_elements[0]
207
+
208
+ # ⭐ 移除或设置主刻度线为 'none'(取消日期间的小竖线)
209
+ if remove_tick_marks:
210
+ majorTickMark_elements = cat_ax.xpath('./c:majorTickMark')
211
+ if majorTickMark_elements:
212
+ # 修改为 'none' 而不是删除元素
213
+ majorTickMark_elements[0].set('val', 'none')
214
+ print(f" → 已移除分类轴主刻度线(日期间的小竖线)")
215
+
216
+ # 同时也设置次刻度线为 'none'
217
+ minorTickMark_elements = cat_ax.xpath('./c:minorTickMark')
218
+ if minorTickMark_elements:
219
+ minorTickMark_elements[0].set('val', 'none')