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.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -0
  3. package/cordis.patch.yml +9 -0
  4. package/engine/pptxgen/__init__.py +8 -0
  5. package/engine/pptxgen/__main__.py +4 -0
  6. package/engine/pptxgen/builtin_template.py +218 -0
  7. package/engine/pptxgen/cli.py +575 -0
  8. package/engine/pptxgen/examples.py +290 -0
  9. package/engine/pptxgen/imagegen.py +564 -0
  10. package/engine/pptxgen/layoutmap.py +211 -0
  11. package/engine/pptxgen/patterns.py +106 -0
  12. package/engine/pptxgen/preview.py +126 -0
  13. package/engine/pptxgen/profile.py +456 -0
  14. package/engine/pptxgen/render.py +1673 -0
  15. package/engine/pptxgen/review.py +184 -0
  16. package/engine/pptxgen/schemacheck.py +122 -0
  17. package/engine/pptxgen/schemas/XAdES.xsd +466 -0
  18. package/engine/pptxgen/schemas/XAdESv141.xsd +15 -0
  19. package/engine/pptxgen/schemas/chartEx.xsd +838 -0
  20. package/engine/pptxgen/schemas/dml-chart.xsd +1499 -0
  21. package/engine/pptxgen/schemas/dml-chartDrawing.xsd +146 -0
  22. package/engine/pptxgen/schemas/dml-diagram.xsd +1085 -0
  23. package/engine/pptxgen/schemas/dml-drawing.xsd +63 -0
  24. package/engine/pptxgen/schemas/dml-lockedCanvas.xsd +11 -0
  25. package/engine/pptxgen/schemas/dml-main.xsd +3081 -0
  26. package/engine/pptxgen/schemas/dml-picture.xsd +23 -0
  27. package/engine/pptxgen/schemas/dml-spreadsheetDrawing.xsd +185 -0
  28. package/engine/pptxgen/schemas/dml-wordprocessingDrawing.xsd +287 -0
  29. package/engine/pptxgen/schemas/drawing-chart2012.xsd +129 -0
  30. package/engine/pptxgen/schemas/markup-compatibility.xsd +95 -0
  31. package/engine/pptxgen/schemas/opc-digSig.xsd +49 -0
  32. package/engine/pptxgen/schemas/opc-relationships.xsd +33 -0
  33. package/engine/pptxgen/schemas/pml.xsd +1676 -0
  34. package/engine/pptxgen/schemas/shared-additionalCharacteristics.xsd +28 -0
  35. package/engine/pptxgen/schemas/shared-bibliography.xsd +144 -0
  36. package/engine/pptxgen/schemas/shared-commonSimpleTypes.xsd +172 -0
  37. package/engine/pptxgen/schemas/shared-customXmlDataProperties.xsd +25 -0
  38. package/engine/pptxgen/schemas/shared-customXmlSchemaProperties.xsd +18 -0
  39. package/engine/pptxgen/schemas/shared-documentPropertiesCustom.xsd +59 -0
  40. package/engine/pptxgen/schemas/shared-documentPropertiesExtended.xsd +56 -0
  41. package/engine/pptxgen/schemas/shared-documentPropertiesVariantTypes.xsd +195 -0
  42. package/engine/pptxgen/schemas/shared-math.xsd +582 -0
  43. package/engine/pptxgen/schemas/shared-relationshipReference.xsd +25 -0
  44. package/engine/pptxgen/schemas/signatureInfo.xsd +103 -0
  45. package/engine/pptxgen/schemas/sml.xsd +4439 -0
  46. package/engine/pptxgen/schemas/visio.xsd +829 -0
  47. package/engine/pptxgen/schemas/vml-main.xsd +570 -0
  48. package/engine/pptxgen/schemas/vml-officeDrawing.xsd +509 -0
  49. package/engine/pptxgen/schemas/vml-presentationDrawing.xsd +12 -0
  50. package/engine/pptxgen/schemas/vml-spreadsheetDrawing.xsd +108 -0
  51. package/engine/pptxgen/schemas/vml-wordprocessingDrawing.xsd +96 -0
  52. package/engine/pptxgen/schemas/vmlDrawing.xsd +36 -0
  53. package/engine/pptxgen/schemas/wml.xsd +3643 -0
  54. package/engine/pptxgen/schemas/word12.xsd +66 -0
  55. package/engine/pptxgen/schemas/xlThreadedComments.xsd +59 -0
  56. package/engine/pptxgen/schemas/xlThreadedComments2.xsd +22 -0
  57. package/engine/pptxgen/schemas/xmldsig-core-schema.xsd +318 -0
  58. package/engine/pptxgen/shapes.py +417 -0
  59. package/engine/pptxgen/spec.py +529 -0
  60. package/engine/pptxgen/template.py +834 -0
  61. package/engine/pptxgen/util.py +218 -0
  62. package/engine/pptxgen/xmlutil.py +298 -0
  63. package/engine/pptxgen.sh +7 -0
  64. package/engine/requirements.txt +9 -0
  65. package/lib/index.js +186 -0
  66. package/package.json +34 -0
  67. package/skills/courseware/SKILL.md +130 -0
  68. package/skills/courseware/references/content-polish.md +121 -0
  69. package/skills/courseware/references/deck-spec.md +155 -0
  70. package/skills/courseware/references/design-review.md +121 -0
  71. package/skills/courseware/references/image-backends.md +94 -0
  72. package/skills/courseware/references/quality-gates.md +71 -0
  73. package/skills/courseware/references/roadmap.md +55 -0
  74. package/skills/courseware/references/template-fidelity.md +100 -0
  75. package/skills/courseware/workflows/generate.md +172 -0
  76. package/skills/courseware/workflows/quick.md +41 -0
  77. package/skills/courseware/workflows/revise.md +40 -0
  78. package/skills/courseware/workflows/routing.md +49 -0
  79. package/skills/courseware/workflows/stages/generate-images.md +74 -0
  80. 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 "$@"
@@ -0,0 +1,9 @@
1
+ # pptxgen 引擎 —— Python 3.10+ 依赖
2
+ # 首次使用:python3 -m venv "<引擎目录>/.venv"
3
+ # "<引擎目录>/.venv/bin/pip" install -r "<引擎目录>/requirements.txt"
4
+ python-pptx>=1.0
5
+ lxml>=5.0
6
+ pymupdf>=1.24
7
+ pillow>=10.0
8
+ pyyaml>=6.0
9
+ certifi>=2024.0