dsh-courseware 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/cordis.patch.yml +9 -0
- package/engine/pptxgen/__init__.py +8 -0
- package/engine/pptxgen/__main__.py +4 -0
- package/engine/pptxgen/builtin_template.py +218 -0
- package/engine/pptxgen/cli.py +575 -0
- package/engine/pptxgen/examples.py +290 -0
- package/engine/pptxgen/imagegen.py +564 -0
- package/engine/pptxgen/layoutmap.py +211 -0
- package/engine/pptxgen/patterns.py +106 -0
- package/engine/pptxgen/preview.py +126 -0
- package/engine/pptxgen/profile.py +456 -0
- package/engine/pptxgen/render.py +1673 -0
- package/engine/pptxgen/review.py +184 -0
- package/engine/pptxgen/schemacheck.py +122 -0
- package/engine/pptxgen/schemas/XAdES.xsd +466 -0
- package/engine/pptxgen/schemas/XAdESv141.xsd +15 -0
- package/engine/pptxgen/schemas/chartEx.xsd +838 -0
- package/engine/pptxgen/schemas/dml-chart.xsd +1499 -0
- package/engine/pptxgen/schemas/dml-chartDrawing.xsd +146 -0
- package/engine/pptxgen/schemas/dml-diagram.xsd +1085 -0
- package/engine/pptxgen/schemas/dml-drawing.xsd +63 -0
- package/engine/pptxgen/schemas/dml-lockedCanvas.xsd +11 -0
- package/engine/pptxgen/schemas/dml-main.xsd +3081 -0
- package/engine/pptxgen/schemas/dml-picture.xsd +23 -0
- package/engine/pptxgen/schemas/dml-spreadsheetDrawing.xsd +185 -0
- package/engine/pptxgen/schemas/dml-wordprocessingDrawing.xsd +287 -0
- package/engine/pptxgen/schemas/drawing-chart2012.xsd +129 -0
- package/engine/pptxgen/schemas/markup-compatibility.xsd +95 -0
- package/engine/pptxgen/schemas/opc-digSig.xsd +49 -0
- package/engine/pptxgen/schemas/opc-relationships.xsd +33 -0
- package/engine/pptxgen/schemas/pml.xsd +1676 -0
- package/engine/pptxgen/schemas/shared-additionalCharacteristics.xsd +28 -0
- package/engine/pptxgen/schemas/shared-bibliography.xsd +144 -0
- package/engine/pptxgen/schemas/shared-commonSimpleTypes.xsd +172 -0
- package/engine/pptxgen/schemas/shared-customXmlDataProperties.xsd +25 -0
- package/engine/pptxgen/schemas/shared-customXmlSchemaProperties.xsd +18 -0
- package/engine/pptxgen/schemas/shared-documentPropertiesCustom.xsd +59 -0
- package/engine/pptxgen/schemas/shared-documentPropertiesExtended.xsd +56 -0
- package/engine/pptxgen/schemas/shared-documentPropertiesVariantTypes.xsd +195 -0
- package/engine/pptxgen/schemas/shared-math.xsd +582 -0
- package/engine/pptxgen/schemas/shared-relationshipReference.xsd +25 -0
- package/engine/pptxgen/schemas/signatureInfo.xsd +103 -0
- package/engine/pptxgen/schemas/sml.xsd +4439 -0
- package/engine/pptxgen/schemas/visio.xsd +829 -0
- package/engine/pptxgen/schemas/vml-main.xsd +570 -0
- package/engine/pptxgen/schemas/vml-officeDrawing.xsd +509 -0
- package/engine/pptxgen/schemas/vml-presentationDrawing.xsd +12 -0
- package/engine/pptxgen/schemas/vml-spreadsheetDrawing.xsd +108 -0
- package/engine/pptxgen/schemas/vml-wordprocessingDrawing.xsd +96 -0
- package/engine/pptxgen/schemas/vmlDrawing.xsd +36 -0
- package/engine/pptxgen/schemas/wml.xsd +3643 -0
- package/engine/pptxgen/schemas/word12.xsd +66 -0
- package/engine/pptxgen/schemas/xlThreadedComments.xsd +59 -0
- package/engine/pptxgen/schemas/xlThreadedComments2.xsd +22 -0
- package/engine/pptxgen/schemas/xmldsig-core-schema.xsd +318 -0
- package/engine/pptxgen/shapes.py +417 -0
- package/engine/pptxgen/spec.py +529 -0
- package/engine/pptxgen/template.py +834 -0
- package/engine/pptxgen/util.py +218 -0
- package/engine/pptxgen/xmlutil.py +298 -0
- package/engine/pptxgen.sh +7 -0
- package/engine/requirements.txt +9 -0
- package/lib/index.js +186 -0
- package/package.json +34 -0
- package/skills/courseware/SKILL.md +130 -0
- package/skills/courseware/references/content-polish.md +121 -0
- package/skills/courseware/references/deck-spec.md +155 -0
- package/skills/courseware/references/design-review.md +121 -0
- package/skills/courseware/references/image-backends.md +94 -0
- package/skills/courseware/references/quality-gates.md +71 -0
- package/skills/courseware/references/roadmap.md +55 -0
- package/skills/courseware/references/template-fidelity.md +100 -0
- package/skills/courseware/workflows/generate.md +172 -0
- package/skills/courseware/workflows/quick.md +41 -0
- package/skills/courseware/workflows/revise.md +40 -0
- package/skills/courseware/workflows/routing.md +49 -0
- package/skills/courseware/workflows/stages/generate-images.md +74 -0
- package/skills/courseware/workflows/template-intake.md +112 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""基础工具:单位换算、文本度量、颜色解析。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Iterable, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
from pptx.util import Emu, Inches, Pt
|
|
9
|
+
|
|
10
|
+
EMU_PER_INCH = 914400
|
|
11
|
+
EMU_PER_PT = 12700
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# --------------------------------------------------------------------------
|
|
15
|
+
# 单位
|
|
16
|
+
# --------------------------------------------------------------------------
|
|
17
|
+
def emu_from_pt(pt: float) -> int:
|
|
18
|
+
return int(round(pt * EMU_PER_PT))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def pt_from_emu(emu: Optional[float]) -> float:
|
|
22
|
+
if emu is None:
|
|
23
|
+
return 0.0
|
|
24
|
+
return float(emu) / EMU_PER_PT
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def inch_from_emu(emu: Optional[float]) -> float:
|
|
28
|
+
if emu is None:
|
|
29
|
+
return 0.0
|
|
30
|
+
return float(emu) / EMU_PER_INCH
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def emu_from_inch(inch: float) -> int:
|
|
34
|
+
return int(round(inch * EMU_PER_INCH))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_length(value, default: int = 0) -> int:
|
|
38
|
+
"""把 '1.5in' / '3cm' / '24pt' / 1.5 / Emu 解析成 EMU。"""
|
|
39
|
+
if value is None:
|
|
40
|
+
return default
|
|
41
|
+
if isinstance(value, (int, float)):
|
|
42
|
+
return emu_from_inch(float(value))
|
|
43
|
+
if isinstance(value, Emu):
|
|
44
|
+
return int(value)
|
|
45
|
+
text = str(value).strip().lower()
|
|
46
|
+
m = re.match(r"^(-?[\d.]+)\s*(in|cm|mm|pt|px|emu)?$", text)
|
|
47
|
+
if not m:
|
|
48
|
+
return default
|
|
49
|
+
num = float(m.group(1))
|
|
50
|
+
unit = m.group(2) or "in"
|
|
51
|
+
factor = {
|
|
52
|
+
"in": EMU_PER_INCH,
|
|
53
|
+
"cm": EMU_PER_INCH / 2.54,
|
|
54
|
+
"mm": EMU_PER_INCH / 25.4,
|
|
55
|
+
"pt": EMU_PER_PT,
|
|
56
|
+
"px": EMU_PER_INCH / 96.0,
|
|
57
|
+
"emu": 1,
|
|
58
|
+
}[unit]
|
|
59
|
+
return int(round(num * factor))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def fmt_len(emu: Optional[int]) -> str:
|
|
63
|
+
"""EMU -> 人类可读字符串(英寸),用于 inspect 输出。"""
|
|
64
|
+
if emu is None:
|
|
65
|
+
return "-"
|
|
66
|
+
v = emu / EMU_PER_INCH
|
|
67
|
+
return f"{v:.2f}in"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# --------------------------------------------------------------------------
|
|
71
|
+
# 颜色
|
|
72
|
+
# --------------------------------------------------------------------------
|
|
73
|
+
_HEX_RE = re.compile(r"^#?([0-9a-fA-F]{6})$")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_color(value) -> Optional[str]:
|
|
77
|
+
"""'#RRGGBB' / 'RRGGBB' / (r,g,b) -> 'RRGGBB'(无 #,大写)。"""
|
|
78
|
+
if value is None:
|
|
79
|
+
return None
|
|
80
|
+
if isinstance(value, (tuple, list)) and len(value) == 3:
|
|
81
|
+
return "".join(f"{int(c) & 0xFF:02X}" for c in value)
|
|
82
|
+
m = _HEX_RE.match(str(value).strip())
|
|
83
|
+
if not m:
|
|
84
|
+
return None
|
|
85
|
+
return m.group(1).upper()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def rgb_tuple(hex_color: str) -> Tuple[int, int, int]:
|
|
89
|
+
h = parse_color(hex_color) or "000000"
|
|
90
|
+
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def relative_luminance(hex_color: str) -> float:
|
|
94
|
+
r, g, b = rgb_tuple(hex_color)
|
|
95
|
+
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255.0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def readable_on(hex_color: str, dark: str = "1A1A1A", light: str = "FFFFFF") -> str:
|
|
99
|
+
"""在给定底色上选择一个可读的文字色。"""
|
|
100
|
+
return dark if relative_luminance(hex_color) > 0.55 else light
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def mix(hex_a: str, hex_b: str, t: float) -> str:
|
|
104
|
+
"""线性混色,t=0 -> a,t=1 -> b。"""
|
|
105
|
+
ra, ga, ba = rgb_tuple(hex_a)
|
|
106
|
+
rb, gb, bb = rgb_tuple(hex_b)
|
|
107
|
+
r = int(round(ra + (rb - ra) * t))
|
|
108
|
+
g = int(round(ga + (gb - ga) * t))
|
|
109
|
+
b = int(round(ba + (bb - ba) * t))
|
|
110
|
+
return f"{r:02X}{g:02X}{b:02X}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# --------------------------------------------------------------------------
|
|
114
|
+
# 文本度量(不依赖字体文件,CJK 场景下足够准)
|
|
115
|
+
# --------------------------------------------------------------------------
|
|
116
|
+
_NARROW = {
|
|
117
|
+
" ": 0.28, "\u3000": 1.0, "!": 0.30, '"': 0.40, "#": 0.60, "$": 0.60,
|
|
118
|
+
"%": 0.90, "&": 0.72, "'": 0.25, "(": 0.35, ")": 0.35, "*": 0.50,
|
|
119
|
+
"+": 0.60, ",": 0.30, "-": 0.35, ".": 0.30, "/": 0.40, ":": 0.30,
|
|
120
|
+
";": 0.30, "<": 0.60, "=": 0.60, ">": 0.60, "?": 0.55, "@": 0.95,
|
|
121
|
+
"[": 0.35, "\\": 0.40, "]": 0.35, "^": 0.55, "_": 0.50, "`": 0.35,
|
|
122
|
+
"{": 0.40, "|": 0.28, "}": 0.40, "~": 0.60,
|
|
123
|
+
"f": 0.36, "i": 0.28, "j": 0.28, "l": 0.28, "r": 0.40, "t": 0.38,
|
|
124
|
+
"I": 0.32, "J": 0.50, "M": 0.88, "W": 0.90,
|
|
125
|
+
"m": 0.86, "w": 0.76,
|
|
126
|
+
"A": 0.68, "B": 0.66, "C": 0.70, "D": 0.72, "E": 0.60, "F": 0.58,
|
|
127
|
+
"G": 0.74, "H": 0.72, "K": 0.68, "L": 0.56, "N": 0.72, "O": 0.76,
|
|
128
|
+
"P": 0.64, "Q": 0.76, "R": 0.68, "S": 0.64, "T": 0.62, "U": 0.72,
|
|
129
|
+
"V": 0.68, "X": 0.66, "Y": 0.62, "Z": 0.60,
|
|
130
|
+
}
|
|
131
|
+
_DEFAULT_ASCII = 0.55
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def char_width_em(ch: str) -> float:
|
|
135
|
+
"""单个字符宽度,单位为 em。"""
|
|
136
|
+
code = ord(ch)
|
|
137
|
+
if code < 0x20:
|
|
138
|
+
return 0.0
|
|
139
|
+
if _is_wide(ch):
|
|
140
|
+
return 1.0
|
|
141
|
+
if ch in _NARROW:
|
|
142
|
+
return _NARROW[ch]
|
|
143
|
+
if "0" <= ch <= "9":
|
|
144
|
+
return 0.55
|
|
145
|
+
if "a" <= ch <= "z":
|
|
146
|
+
return 0.55
|
|
147
|
+
if "A" <= ch <= "Z":
|
|
148
|
+
return 0.68
|
|
149
|
+
return _DEFAULT_ASCII
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _is_wide(ch: str) -> bool:
|
|
153
|
+
code = ord(ch)
|
|
154
|
+
return (
|
|
155
|
+
0x1100 <= code <= 0x115F
|
|
156
|
+
or 0x2E80 <= code <= 0xA4CF
|
|
157
|
+
or 0xA960 <= code <= 0xA97F
|
|
158
|
+
or 0xAC00 <= code <= 0xD7A3
|
|
159
|
+
or 0xF900 <= code <= 0xFAFF
|
|
160
|
+
or 0xFE10 <= code <= 0xFE19
|
|
161
|
+
or 0xFE30 <= code <= 0xFE6F
|
|
162
|
+
or 0xFF00 <= code <= 0xFF60
|
|
163
|
+
or 0xFFE0 <= code <= 0xFFE6
|
|
164
|
+
or 0x1F300 <= code <= 0x1FAFF
|
|
165
|
+
or 0x20000 <= code <= 0x3FFFD
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def text_width_em(text: str, bold: bool = False) -> float:
|
|
170
|
+
w = sum(char_width_em(c) for c in text)
|
|
171
|
+
return w * (1.04 if bold else 1.0)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def text_width_pt(text: str, size_pt: float, bold: bool = False) -> float:
|
|
175
|
+
return text_width_em(text, bold) * size_pt
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def wrap_lines(text: str, size_pt: float, box_width_pt: float, bold: bool = False) -> int:
|
|
179
|
+
"""粗略估算在给定宽度下会折成几行(按字符贪心,中文逐字断行)。"""
|
|
180
|
+
if box_width_pt <= 1 or size_pt <= 0:
|
|
181
|
+
return 1
|
|
182
|
+
lines = 1
|
|
183
|
+
cur = 0.0
|
|
184
|
+
for ch in text:
|
|
185
|
+
if ch == "\n":
|
|
186
|
+
lines += 1
|
|
187
|
+
cur = 0.0
|
|
188
|
+
continue
|
|
189
|
+
w = char_width_em(ch) * size_pt * (1.04 if bold else 1.0)
|
|
190
|
+
if cur + w > box_width_pt and cur > 0:
|
|
191
|
+
lines += 1
|
|
192
|
+
cur = w
|
|
193
|
+
else:
|
|
194
|
+
cur += w
|
|
195
|
+
return lines
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def split_paragraphs(text: str) -> list:
|
|
199
|
+
if text is None:
|
|
200
|
+
return []
|
|
201
|
+
return [p for p in str(text).replace("\r\n", "\n").replace("\r", "\n").split("\n")]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def clean_lines(items: Optional[Iterable]) -> list:
|
|
205
|
+
"""把 YAML 里可能是 str 或 list 的字段规整成字符串列表。"""
|
|
206
|
+
if items is None:
|
|
207
|
+
return []
|
|
208
|
+
if isinstance(items, str):
|
|
209
|
+
return split_paragraphs(items)
|
|
210
|
+
out = []
|
|
211
|
+
for it in items:
|
|
212
|
+
if it is None:
|
|
213
|
+
continue
|
|
214
|
+
if isinstance(it, dict):
|
|
215
|
+
out.append(it)
|
|
216
|
+
else:
|
|
217
|
+
out.append(str(it))
|
|
218
|
+
return out
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""OOXML 底层辅助:命名空间、背景读取、形状克隆、关系重映射。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
from typing import Iterator, List, Optional
|
|
7
|
+
|
|
8
|
+
from pptx.oxml.ns import qn
|
|
9
|
+
|
|
10
|
+
A = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
|
11
|
+
P = "http://schemas.openxmlformats.org/presentationml/2006/main"
|
|
12
|
+
R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
|
13
|
+
RT_IMAGE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# --------------------------------------------------------------------------
|
|
17
|
+
# 背景读取
|
|
18
|
+
# --------------------------------------------------------------------------
|
|
19
|
+
def cSld_of(element):
|
|
20
|
+
"""从 sld / sldLayout / sldMaster 元素上取 cSld。"""
|
|
21
|
+
return element.find(qn("p:cSld"))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_bg(element):
|
|
25
|
+
"""取出 <p:bg>,没有则返回 None。"""
|
|
26
|
+
cSld = cSld_of(element)
|
|
27
|
+
if cSld is None:
|
|
28
|
+
return None
|
|
29
|
+
return cSld.find(qn("p:bg"))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def describe_bg(bg) -> Optional[dict]:
|
|
33
|
+
"""把 <p:bg> 解析成 {kind, rId?, color?, lumMod?, lumOff?, theme_idx?}。"""
|
|
34
|
+
if bg is None:
|
|
35
|
+
return None
|
|
36
|
+
bgPr = bg.find(qn("p:bgPr"))
|
|
37
|
+
if bgPr is not None:
|
|
38
|
+
blip = bgPr.find(qn("a:blipFill"))
|
|
39
|
+
if blip is not None:
|
|
40
|
+
blipEl = blip.find(qn("a:blip"))
|
|
41
|
+
rId = blipEl.get(qn("r:embed")) if blipEl is not None else None
|
|
42
|
+
return {"kind": "picture", "rId": rId}
|
|
43
|
+
solid = bgPr.find(qn("a:solidFill"))
|
|
44
|
+
if solid is not None:
|
|
45
|
+
color = _read_color(solid)
|
|
46
|
+
if color:
|
|
47
|
+
return {"kind": "solid", "color": color}
|
|
48
|
+
grad = bgPr.find(qn("a:gradFill"))
|
|
49
|
+
if grad is not None:
|
|
50
|
+
return {"kind": "gradient"}
|
|
51
|
+
nofill = bgPr.find(qn("a:noFill"))
|
|
52
|
+
if nofill is not None:
|
|
53
|
+
return {"kind": "none"}
|
|
54
|
+
bgRef = bg.find(qn("p:bgRef"))
|
|
55
|
+
if bgRef is not None:
|
|
56
|
+
idx = bgRef.get("idx")
|
|
57
|
+
color = _read_color(bgRef)
|
|
58
|
+
return {"kind": "ref", "theme_idx": int(idx) if idx else 0, "color": color}
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _read_color(parent) -> Optional[str]:
|
|
63
|
+
"""从含 srgbClr / sysClr 的节点里读颜色(忽略 lumMod/lumOff 的精确计算)。"""
|
|
64
|
+
if parent is None:
|
|
65
|
+
return None
|
|
66
|
+
srgb = parent.find(qn("a:srgbClr"))
|
|
67
|
+
if srgb is not None:
|
|
68
|
+
return (srgb.get("val") or "").upper() or None
|
|
69
|
+
sysclr = parent.find(qn("a:sysClr"))
|
|
70
|
+
if sysclr is not None:
|
|
71
|
+
return (sysclr.get("lastClr") or "").upper() or None
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def resolve_bg(slide=None, layout=None, master=None) -> Optional[dict]:
|
|
76
|
+
"""按 slide -> layout -> master 的顺序解析背景,返回第一个命中的。"""
|
|
77
|
+
for source, owner in ((slide, "slide"), (layout, "layout"), (master, "master")):
|
|
78
|
+
if source is None:
|
|
79
|
+
continue
|
|
80
|
+
bg = get_bg(source._element)
|
|
81
|
+
info = describe_bg(bg)
|
|
82
|
+
if info and info.get("kind") != "none":
|
|
83
|
+
info["from"] = owner
|
|
84
|
+
info["element"] = bg
|
|
85
|
+
info["owner"] = source
|
|
86
|
+
return info
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# --------------------------------------------------------------------------
|
|
91
|
+
# 形状遍历
|
|
92
|
+
# --------------------------------------------------------------------------
|
|
93
|
+
def iter_shape_elements(spTree) -> Iterator:
|
|
94
|
+
for el in spTree:
|
|
95
|
+
tag = el.tag
|
|
96
|
+
if tag in (qn("p:sp"), qn("p:pic"), qn("p:graphicFrame"), qn("p:grpSp"), qn("p:cxnSp")):
|
|
97
|
+
yield el
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def is_placeholder(element) -> bool:
|
|
101
|
+
nv = element.find(qn("p:nvSpPr"))
|
|
102
|
+
if nv is None:
|
|
103
|
+
nv = element.find(qn("p:nvPicPr"))
|
|
104
|
+
if nv is None:
|
|
105
|
+
nv = element.find(qn("p:nvGraphicFramePr"))
|
|
106
|
+
if nv is None:
|
|
107
|
+
return False
|
|
108
|
+
nvPr = nv.find(qn("p:nvPr"))
|
|
109
|
+
if nvPr is None:
|
|
110
|
+
return False
|
|
111
|
+
return nvPr.find(qn("p:ph")) is not None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def placeholder_idx(element) -> Optional[int]:
|
|
115
|
+
nv = element.find(qn("p:nvSpPr"))
|
|
116
|
+
if nv is None:
|
|
117
|
+
return None
|
|
118
|
+
nvPr = nv.find(qn("p:nvPr"))
|
|
119
|
+
if nvPr is None:
|
|
120
|
+
return None
|
|
121
|
+
ph = nvPr.find(qn("p:ph"))
|
|
122
|
+
if ph is None:
|
|
123
|
+
return None
|
|
124
|
+
idx = ph.get("idx")
|
|
125
|
+
return int(idx) if idx is not None else 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def placeholder_type(element) -> Optional[str]:
|
|
129
|
+
nv = element.find(qn("p:nvSpPr"))
|
|
130
|
+
if nv is None:
|
|
131
|
+
return None
|
|
132
|
+
nvPr = nv.find(qn("p:nvPr"))
|
|
133
|
+
if nvPr is None:
|
|
134
|
+
return None
|
|
135
|
+
ph = nvPr.find(qn("p:ph"))
|
|
136
|
+
if ph is None:
|
|
137
|
+
return None
|
|
138
|
+
return (ph.get("type") or "body").lower()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def shape_name(element) -> str:
|
|
142
|
+
for tag in ("p:nvSpPr", "p:nvPicPr", "p:nvGraphicFramePr", "p:nvGrpSpPr", "p:nvCxnSpPr"):
|
|
143
|
+
nv = element.find(qn(tag))
|
|
144
|
+
if nv is not None:
|
|
145
|
+
cNvPr = nv.find(qn("p:cNvPr"))
|
|
146
|
+
if cNvPr is not None:
|
|
147
|
+
return cNvPr.get("name") or ""
|
|
148
|
+
return ""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def shape_off_ext(element):
|
|
152
|
+
"""返回 (x, y, cx, cy),可能部分为 None。"""
|
|
153
|
+
xfrm = element.find(f".//{qn('a:xfrm')}")
|
|
154
|
+
if xfrm is None:
|
|
155
|
+
return None, None, None, None
|
|
156
|
+
off = xfrm.find(qn("a:off"))
|
|
157
|
+
ext = xfrm.find(qn("a:ext"))
|
|
158
|
+
x = int(off.get("x")) if off is not None and off.get("x") else None
|
|
159
|
+
y = int(off.get("y")) if off is not None and off.get("y") else None
|
|
160
|
+
cx = int(ext.get("cx")) if ext is not None and ext.get("cx") else None
|
|
161
|
+
cy = int(ext.get("cy")) if ext is not None and ext.get("cy") else None
|
|
162
|
+
return x, y, cx, cy
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def set_shape_off_ext(element, x, y, cx, cy) -> None:
|
|
166
|
+
xfrm = element.find(f".//{qn('a:xfrm')}")
|
|
167
|
+
if xfrm is None:
|
|
168
|
+
return
|
|
169
|
+
off = xfrm.find(qn("a:off"))
|
|
170
|
+
ext = xfrm.find(qn("a:ext"))
|
|
171
|
+
if off is not None:
|
|
172
|
+
off.set("x", str(int(x)))
|
|
173
|
+
off.set("y", str(int(y)))
|
|
174
|
+
if ext is not None:
|
|
175
|
+
ext.set("cx", str(int(cx)))
|
|
176
|
+
ext.set("cy", str(int(cy)))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def blip_rid(element) -> Optional[str]:
|
|
180
|
+
blip = element.find(f".//{qn('a:blip')}")
|
|
181
|
+
if blip is None:
|
|
182
|
+
return None
|
|
183
|
+
return blip.get(qn("r:embed"))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def set_blip_rid(element, rId: str) -> None:
|
|
187
|
+
blip = element.find(f".//{qn('a:blip')}")
|
|
188
|
+
if blip is not None:
|
|
189
|
+
blip.set(qn("r:embed"), rId)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def next_shape_id(spTree) -> int:
|
|
193
|
+
ids = [1]
|
|
194
|
+
for cNvPr in spTree.iter(qn("p:cNvPr")):
|
|
195
|
+
try:
|
|
196
|
+
ids.append(int(cNvPr.get("id")))
|
|
197
|
+
except (TypeError, ValueError):
|
|
198
|
+
continue
|
|
199
|
+
return max(ids) + 1
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def renumber_shape_id(element, new_id: int) -> None:
|
|
203
|
+
for cNvPr in element.iter(qn("p:cNvPr")):
|
|
204
|
+
cNvPr.set("id", str(new_id))
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def strip_placeholder(element) -> None:
|
|
209
|
+
"""移除 p:ph,使克隆过来的形状不再是占位符(避免版式冲突)。"""
|
|
210
|
+
for nvPr in element.iter(qn("p:nvPr")):
|
|
211
|
+
ph = nvPr.find(qn("p:ph"))
|
|
212
|
+
if ph is not None:
|
|
213
|
+
nvPr.remove(ph)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def remap_rels(src_part, dest_part, element) -> None:
|
|
217
|
+
"""把 element 里所有 r:embed / r:link 重映射到 dest_part 的关系 id。"""
|
|
218
|
+
for attr in (qn("r:embed"), qn("r:link")):
|
|
219
|
+
for node in element.iter():
|
|
220
|
+
rId = node.get(attr)
|
|
221
|
+
if not rId:
|
|
222
|
+
continue
|
|
223
|
+
try:
|
|
224
|
+
part = src_part.related_part(rId)
|
|
225
|
+
except KeyError:
|
|
226
|
+
node.attrib.pop(attr, None)
|
|
227
|
+
continue
|
|
228
|
+
new_rId = dest_part.relate_to(part, RT_IMAGE)
|
|
229
|
+
node.set(attr, new_rId)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def clone_shape(src_slide, element, dest_slide):
|
|
233
|
+
"""把源幻灯片上的一个形状深拷贝到目标幻灯片,处理关系与 id。"""
|
|
234
|
+
new_el = copy.deepcopy(element)
|
|
235
|
+
strip_placeholder(new_el)
|
|
236
|
+
remap_rels(src_slide.part, dest_slide.part, new_el)
|
|
237
|
+
spTree = dest_slide.shapes._spTree
|
|
238
|
+
renumber_shape_id(new_el, next_shape_id(spTree))
|
|
239
|
+
spTree.append(new_el)
|
|
240
|
+
return new_el
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def clone_bg(src_owner, bg_element, dest_slide):
|
|
244
|
+
"""把 <p:bg> 深拷贝到目标幻灯片并重映射图片关系。"""
|
|
245
|
+
new_bg = copy.deepcopy(bg_element)
|
|
246
|
+
remap_rels(src_owner.part, dest_slide.part, new_bg)
|
|
247
|
+
cSld = cSld_of(dest_slide._element)
|
|
248
|
+
old = cSld.find(qn("p:bg"))
|
|
249
|
+
if old is not None:
|
|
250
|
+
cSld.remove(old)
|
|
251
|
+
# <p:bg> 必须是 cSld 的第一个子元素
|
|
252
|
+
cSld.insert(0, new_bg)
|
|
253
|
+
return new_bg
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def remove_bg(slide) -> None:
|
|
257
|
+
cSld = cSld_of(slide._element)
|
|
258
|
+
if cSld is None:
|
|
259
|
+
return
|
|
260
|
+
bg = cSld.find(qn("p:bg"))
|
|
261
|
+
if bg is not None:
|
|
262
|
+
cSld.remove(bg)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def full_bleed_ratio(element, slide_w: int, slide_h: int) -> float:
|
|
266
|
+
"""形状覆盖画布的比例(按面积),用于识别整页背景图。"""
|
|
267
|
+
x, y, cx, cy = shape_off_ext(element)
|
|
268
|
+
if None in (x, y, cx, cy) or slide_w <= 0 or slide_h <= 0:
|
|
269
|
+
return 0.0
|
|
270
|
+
area = (cx * cy) / float(slide_w * slide_h)
|
|
271
|
+
near_origin = abs(x) <= slide_w * 0.06 and abs(y) <= slide_h * 0.06
|
|
272
|
+
return area if near_origin else min(area, 0.5)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def solid_fill_luminance(element, theme_colors=None) -> Optional[float]:
|
|
276
|
+
"""形状纯色填充的相对亮度(0=黑,1=白);不是纯色填充时返回 None。
|
|
277
|
+
|
|
278
|
+
有的模板不用 <p:bg>,而是拿一个整页纯色形状当背景——判断"深色模板"时要认得出。
|
|
279
|
+
"""
|
|
280
|
+
from .util import relative_luminance
|
|
281
|
+
|
|
282
|
+
spPr = element.find(qn("p:spPr"))
|
|
283
|
+
if spPr is None:
|
|
284
|
+
return None
|
|
285
|
+
solid = spPr.find(qn("a:solidFill"))
|
|
286
|
+
if solid is None:
|
|
287
|
+
return None
|
|
288
|
+
srgb = solid.find(qn("a:srgbClr"))
|
|
289
|
+
color = None
|
|
290
|
+
if srgb is not None:
|
|
291
|
+
color = (srgb.get("val") or "").upper() or None
|
|
292
|
+
else:
|
|
293
|
+
scheme = solid.find(qn("a:schemeClr"))
|
|
294
|
+
if scheme is not None:
|
|
295
|
+
color = (theme_colors or {}).get(scheme.get("val"))
|
|
296
|
+
if not color:
|
|
297
|
+
return None
|
|
298
|
+
return relative_luminance(color)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# courseware 引擎入口 —— 可在任意目录调用。
|
|
3
|
+
# /path/to/engine/pptxgen.sh build decks/xxx.yaml
|
|
4
|
+
# 引擎目录自动加入 PYTHONPATH,因此不依赖当前工作目录。
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
7
|
+
exec env PYTHONPATH="$DIR${PYTHONPATH:+:$PYTHONPATH}" "$DIR/.venv/bin/python" -m pptxgen "$@"
|