@lark-apaas/coding-steering 0.1.31 → 0.1.32-beta.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.
@@ -0,0 +1,699 @@
1
+ #!/usr/bin/env python3
2
+ """OOXML parsing primitives for stage-1 extraction (S3 color resolution + S4 shape facts).
3
+
4
+ All length quantities are normalised to px @1920-wide canvas with a single factor
5
+ (`Units.f` = 1920 / sldSz_cx), so coordinates, font sizes, letter spacing, line
6
+ spacing, insets and radii are directly comparable across samples with different
7
+ EMU canvases (方案 v0.2 §1 S4 归一化统一声明).
8
+ """
9
+ import posixpath
10
+ from xml.etree import ElementTree as ET
11
+
12
+ NS = {
13
+ 'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
14
+ 'p': 'http://schemas.openxmlformats.org/presentationml/2006/main',
15
+ 'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
16
+ 'rel': 'http://schemas.openxmlformats.org/package/2006/relationships',
17
+ 'ct': 'http://schemas.openxmlformats.org/package/2006/content-types',
18
+ 'p15': 'http://schemas.microsoft.com/office/powerpoint/2012/main',
19
+ 'asvg': 'http://schemas.microsoft.com/office/drawing/2016/SVG/main',
20
+ }
21
+ R_EMBED = '{%s}embed' % NS['r']
22
+ R_LINK = '{%s}link' % NS['r']
23
+ R_ID = '{%s}id' % NS['r']
24
+
25
+ EMU_PER_PT = 12700.0
26
+ CANVAS_W_PX = 1920
27
+
28
+ # clrScheme slot names vs the p:clrMap "map name" keys that point at them.
29
+ SCHEME_SLOTS = ('dk1', 'lt1', 'dk2', 'lt2', 'accent1', 'accent2', 'accent3',
30
+ 'accent4', 'accent5', 'accent6', 'hlink', 'folHlink')
31
+ OFFICE_DEFAULT_ACCENTS = {'4472C4', 'ED7D31', 'A5A5A5', 'FFC000', '5B9BD5', '70AD47'}
32
+ OFFICE_DEFAULT_FONTS = {'Calibri', 'Calibri Light', 'Cambria', 'Aptos', 'Aptos Display'}
33
+
34
+ # Weight tokens carried in font names. Keys are lowercased and space/hyphen-stripped.
35
+ # Includes the 31-char-truncation artefacts seen in real files (SemiBol / DemiBol).
36
+ WEIGHT_TOKENS = {
37
+ 'thin': 100, 'hairline': 100,
38
+ 'extralight': 200, 'ultralight': 200, 'extralight': 200,
39
+ 'light': 300,
40
+ 'regular': 400, 'normal': 400, 'book': 400, 'roman': 400,
41
+ 'medium': 500,
42
+ 'semibold': 600, 'semibol': 600, 'demibold': 600, 'demibol': 600, 'demi': 600,
43
+ 'bold': 700,
44
+ 'extrabold': 800, 'ultrabold': 800,
45
+ 'black': 900, 'heavy': 900,
46
+ }
47
+ ITALIC_TOKENS = {'italic', 'oblique', 'it'}
48
+
49
+ # Deterministic latin<->CJK alias hints. Emitted as a separate `alias_group` field,
50
+ # never merged into counts, so the audit trail stays intact (see STAGE1-REPORT deviations).
51
+ FONT_ALIAS_GROUPS = {
52
+ 'fzlantinghei': ('方正兰亭黑', 'fzlantingheipro', 'fzlthpro', 'fzlthpros'),
53
+ 'bytesans': ('字节跳动', 'bytesans'),
54
+ }
55
+
56
+
57
+ def local(tag):
58
+ return tag.rsplit('}', 1)[-1]
59
+
60
+
61
+ def resolve_part(part, target):
62
+ """rels target (possibly '../media/x.png') -> package-absolute part name."""
63
+ if target.startswith('/'):
64
+ return target.lstrip('/')
65
+ return posixpath.normpath(posixpath.join(posixpath.dirname(part), target))
66
+
67
+
68
+ class Units:
69
+ """Single normalisation factor shared by every length quantity."""
70
+
71
+ def __init__(self, cx, cy):
72
+ self.cx, self.cy = cx, cy
73
+ self.f = CANVAS_W_PX / float(cx)
74
+ self.w = CANVAS_W_PX
75
+ self.h = round(cy * self.f)
76
+ self.emu_per_px = cx / float(CANVAS_W_PX)
77
+
78
+ def px(self, emu, nd=1):
79
+ if emu is None:
80
+ return None
81
+ return round(int(emu) * self.f, nd)
82
+
83
+ def pt100(self, v, nd=1):
84
+ """sz / spc / spcPts style values (1/100 pt) -> px."""
85
+ if v is None:
86
+ return None
87
+ return round(float(v) / 100.0 * EMU_PER_PT * self.f, nd)
88
+
89
+ def eighth_pt(self, v, nd=1):
90
+ """p15:guide pos (1/8 pt) -> px."""
91
+ if v is None:
92
+ return None
93
+ return round(float(v) / 8.0 * EMU_PER_PT * self.f, nd)
94
+
95
+
96
+ # --------------------------------------------------------------------- colour
97
+ def _lin(c):
98
+ c = c / 255.0
99
+ return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
100
+
101
+
102
+ def _srgb(c):
103
+ c = max(0.0, min(1.0, c))
104
+ v = c * 12.92 if c <= 0.0031308 else 1.055 * (c ** (1 / 2.4)) - 0.055
105
+ return max(0, min(255, int(round(v * 255))))
106
+
107
+
108
+ def _rgb_to_hsl(r, g, b):
109
+ r, g, b = r / 255.0, g / 255.0, b / 255.0
110
+ mx, mn = max(r, g, b), min(r, g, b)
111
+ l = (mx + mn) / 2
112
+ if mx == mn:
113
+ return 0.0, 0.0, l
114
+ d = mx - mn
115
+ s = d / (2 - mx - mn) if l > 0.5 else d / (mx + mn)
116
+ if mx == r:
117
+ h = ((g - b) / d) % 6
118
+ elif mx == g:
119
+ h = (b - r) / d + 2
120
+ else:
121
+ h = (r - g) / d + 4
122
+ return h / 6, s, l
123
+
124
+
125
+ def _hue(p, q, t):
126
+ t = t % 1.0
127
+ if t < 1 / 6:
128
+ return p + (q - p) * 6 * t
129
+ if t < 1 / 2:
130
+ return q
131
+ if t < 2 / 3:
132
+ return p + (q - p) * (2 / 3 - t) * 6
133
+ return p
134
+
135
+
136
+ def _hsl_to_rgb(h, s, l):
137
+ if s == 0:
138
+ v = int(round(l * 255))
139
+ return v, v, v
140
+ q = l * (1 + s) if l < 0.5 else l + s - l * s
141
+ p = 2 * l - q
142
+ return (int(round(_hue(p, q, h + 1 / 3) * 255)),
143
+ int(round(_hue(p, q, h) * 255)),
144
+ int(round(_hue(p, q, h - 1 / 3) * 255)))
145
+
146
+
147
+ COLOR_TAGS = ('srgbClr', 'schemeClr', 'sysClr', 'prstClr', 'scrgbClr', 'hslClr')
148
+ PRST_CLR = {'black': '000000', 'white': 'FFFFFF', 'red': 'FF0000', 'green': '008000',
149
+ 'blue': '0000FF', 'gray': '808080', 'grey': '808080', 'yellow': 'FFFF00'}
150
+
151
+
152
+ def read_color(el):
153
+ """Parse one colour element into a raw, unresolved descriptor."""
154
+ if el is None:
155
+ return None
156
+ tag = local(el.tag)
157
+ if tag not in COLOR_TAGS:
158
+ return None
159
+ raw = {'type': tag}
160
+ if tag == 'sysClr':
161
+ raw['val'] = el.get('lastClr')
162
+ raw['sys'] = el.get('val')
163
+ elif tag == 'scrgbClr':
164
+ # r/g/b are linear-space percentages in 1/1000 % units.
165
+ raw['val'] = '%02X%02X%02X' % tuple(
166
+ _srgb(int(el.get(k, 0)) / 100000.0) for k in ('r', 'g', 'b'))
167
+ elif tag == 'hslClr':
168
+ r, g, b = _hsl_to_rgb(int(el.get('hue', 0)) / 21600000.0,
169
+ int(el.get('sat', 0)) / 100000.0,
170
+ int(el.get('lum', 0)) / 100000.0)
171
+ raw['val'] = '%02X%02X%02X' % (r, g, b)
172
+ elif tag == 'prstClr':
173
+ raw['val'] = PRST_CLR.get(el.get('val', ''), None)
174
+ raw['prst'] = el.get('val')
175
+ else:
176
+ raw['val'] = el.get('val')
177
+ mods = []
178
+ for m in el:
179
+ mods.append((local(m.tag), m.get('val')))
180
+ if mods:
181
+ raw['mods'] = mods
182
+ return raw
183
+
184
+
185
+ def raw_token(raw):
186
+ """Stable audit key, e.g. 'schemeClr:bg1(lumMod=60000)'."""
187
+ if not raw:
188
+ return None
189
+ t = '%s:%s' % ('schemeClr' if raw['type'] == 'schemeClr' else raw['type'], raw.get('val'))
190
+ if raw.get('mods'):
191
+ t += '(' + ','.join('%s=%s' % (k, v) for k, v in raw['mods']) + ')'
192
+ return t
193
+
194
+
195
+ def resolve_color(raw, clrmap=None, clrscheme=None, phclr=None):
196
+ """raw descriptor + master clrMap + theme clrScheme -> {hex, alpha, resolved}.
197
+
198
+ This is the schemeClr -> clrMap -> clrScheme indirection the PRD's
199
+ "do not resolve inheritance" rule does NOT cover (方案 v0.2 §3 难点 3).
200
+ """
201
+ if not raw:
202
+ return None
203
+ hexv, unresolved = None, None
204
+ if raw['type'] == 'schemeClr':
205
+ name = raw.get('val')
206
+ if name == 'phClr':
207
+ hexv = phclr
208
+ if hexv is None:
209
+ unresolved = 'phClr'
210
+ else:
211
+ slot = (clrmap or {}).get(name, name)
212
+ ent = (clrscheme or {}).get(slot)
213
+ if ent is None:
214
+ unresolved = 'no-scheme:%s' % name
215
+ else:
216
+ hexv = ent
217
+ else:
218
+ hexv = raw.get('val')
219
+ if hexv is None:
220
+ unresolved = 'unknown:%s' % raw['type']
221
+ out = {'raw': raw_token(raw), 'alpha': 100.0}
222
+ if unresolved or not hexv:
223
+ out['unresolved'] = unresolved or 'empty'
224
+ return out
225
+ hexv = hexv.lstrip('#').upper()
226
+ try:
227
+ r, g, b = int(hexv[0:2], 16), int(hexv[2:4], 16), int(hexv[4:6], 16)
228
+ except (ValueError, IndexError):
229
+ out['unresolved'] = 'bad-hex:%s' % hexv
230
+ return out
231
+ alpha = 100.0
232
+ for name, val in raw.get('mods', []):
233
+ if val is None:
234
+ continue
235
+ v = int(val)
236
+ if name == 'alpha':
237
+ alpha = v / 1000.0
238
+ elif name == 'lumMod':
239
+ h, s, l = _rgb_to_hsl(r, g, b)
240
+ r, g, b = _hsl_to_rgb(h, s, l * v / 100000.0)
241
+ elif name == 'lumOff':
242
+ h, s, l = _rgb_to_hsl(r, g, b)
243
+ r, g, b = _hsl_to_rgb(h, s, min(1.0, l + v / 100000.0))
244
+ elif name == 'satMod':
245
+ h, s, l = _rgb_to_hsl(r, g, b)
246
+ r, g, b = _hsl_to_rgb(h, min(1.0, s * v / 100000.0), l)
247
+ elif name == 'satOff':
248
+ h, s, l = _rgb_to_hsl(r, g, b)
249
+ r, g, b = _hsl_to_rgb(h, min(1.0, max(0.0, s + v / 100000.0)), l)
250
+ elif name == 'shade':
251
+ f = v / 100000.0
252
+ r, g, b = (_srgb(_lin(r) * f), _srgb(_lin(g) * f), _srgb(_lin(b) * f))
253
+ elif name == 'tint':
254
+ f = v / 100000.0
255
+ r, g, b = (_srgb(_lin(r) * f + (1 - f)), _srgb(_lin(g) * f + (1 - f)),
256
+ _srgb(_lin(b) * f + (1 - f)))
257
+ out['hex'] = '#%02X%02X%02X' % (r, g, b)
258
+ out['alpha'] = round(alpha, 1)
259
+ out['resolved'] = out['hex'] if alpha >= 100 else 'rgba(%d,%d,%d,%.3g)' % (r, g, b, alpha / 100.0)
260
+ return out
261
+
262
+
263
+ def luminance(hexv):
264
+ hexv = hexv.lstrip('#')
265
+ r, g, b = int(hexv[0:2], 16), int(hexv[2:4], 16), int(hexv[4:6], 16)
266
+ return 0.2126 * _lin(r) + 0.7152 * _lin(g) + 0.0722 * _lin(b)
267
+
268
+
269
+ # ------------------------------------------------------------------ fill/line
270
+ def describe_fill(container, ctx):
271
+ """solidFill / gradFill / blipFill / pattFill / noFill descriptor."""
272
+ if container is None:
273
+ return None
274
+ sf = container.find('a:solidFill', NS)
275
+ if sf is not None:
276
+ for ch in sf:
277
+ c = resolve_color(read_color(ch), ctx.clrmap, ctx.clrscheme)
278
+ if c:
279
+ return {'type': 'solid', 'color': c}
280
+ return {'type': 'solid'}
281
+ gf = container.find('a:gradFill', NS)
282
+ if gf is not None:
283
+ stops = []
284
+ for gs in gf.findall('a:gsLst/a:gs', NS):
285
+ c = None
286
+ for ch in gs:
287
+ c = resolve_color(read_color(ch), ctx.clrmap, ctx.clrscheme)
288
+ if c:
289
+ break
290
+ stops.append({'pos': round(int(gs.get('pos', 0)) / 1000.0, 2), 'color': c})
291
+ d = {'type': 'gradient', 'stops': stops}
292
+ lin = gf.find('a:lin', NS)
293
+ if lin is not None:
294
+ d['angle_deg'] = round(int(lin.get('ang', 0)) / 60000.0, 2)
295
+ d['scaled'] = lin.get('scaled') == '1'
296
+ path = gf.find('a:path', NS)
297
+ if path is not None:
298
+ d['path'] = path.get('path')
299
+ return d
300
+ bf = container.find('a:blipFill', NS)
301
+ if bf is not None:
302
+ blip = bf.find('a:blip', NS)
303
+ rid = blip.get(R_EMBED) if blip is not None else None
304
+ d = {'type': 'image', 'media': ctx.media_of(rid)}
305
+ sr = bf.find('a:srcRect', NS)
306
+ if sr is not None and sr.attrib:
307
+ d['crop'] = {k: round(int(v) / 1000.0, 2) for k, v in sr.attrib.items()}
308
+ if bf.find('a:tile', NS) is not None:
309
+ d['tile'] = True
310
+ return d
311
+ pf = container.find('a:pattFill', NS)
312
+ if pf is not None:
313
+ return {'type': 'pattern', 'preset': pf.get('prst')}
314
+ if container.find('a:noFill', NS) is not None:
315
+ return {'type': 'none'}
316
+ return None
317
+
318
+
319
+ def describe_line(spPr, ctx):
320
+ ln = spPr.find('a:ln', NS) if spPr is not None else None
321
+ if ln is None:
322
+ return None
323
+ d = {}
324
+ if ln.get('w'):
325
+ d['w_px'] = ctx.units.px(ln.get('w'), 2)
326
+ if ln.find('a:noFill', NS) is not None:
327
+ d['none'] = True
328
+ f = describe_fill(ln, ctx)
329
+ if f and f.get('type') == 'solid':
330
+ d['color'] = f.get('color')
331
+ elif f and f.get('type') == 'gradient':
332
+ d['gradient'] = f
333
+ dash = ln.find('a:prstDash', NS)
334
+ if dash is not None:
335
+ d['dash'] = dash.get('val')
336
+ for tag in ('headEnd', 'tailEnd'):
337
+ e = ln.find('a:%s' % tag, NS)
338
+ if e is not None and e.get('type') not in (None, 'none'):
339
+ d[tag] = e.get('type')
340
+ return d or None
341
+
342
+
343
+ EFFECT_LEN_ATTRS = ('blurRad', 'dist', 'sx', 'sy', 'rad')
344
+
345
+
346
+ def describe_effects(spPr, ctx):
347
+ if spPr is None:
348
+ return None
349
+ lst = spPr.find('a:effectLst', NS)
350
+ out = []
351
+ if lst is not None:
352
+ for e in lst:
353
+ d = {'type': local(e.tag)}
354
+ for k, v in e.attrib.items():
355
+ if k in ('blurRad', 'dist', 'rad'):
356
+ d[k + '_px'] = ctx.units.px(v, 2)
357
+ elif k == 'dir':
358
+ d['dir_deg'] = round(int(v) / 60000.0, 1)
359
+ else:
360
+ d[k] = v
361
+ for ch in e:
362
+ c = resolve_color(read_color(ch), ctx.clrmap, ctx.clrscheme)
363
+ if c:
364
+ d['color'] = c
365
+ break
366
+ out.append(d)
367
+ if spPr.find('a:effectDag', NS) is not None:
368
+ out.append({'type': 'effectDag'})
369
+ scene = spPr.find('a:scene3d', NS)
370
+ if scene is not None:
371
+ out.append({'type': 'scene3d'})
372
+ return out or None
373
+
374
+
375
+ def describe_geom(spPr, ctx, w_px=None, h_px=None):
376
+ if spPr is None:
377
+ return None, None
378
+ pg = spPr.find('a:prstGeom', NS)
379
+ if pg is None:
380
+ if spPr.find('a:custGeom', NS) is not None:
381
+ return {'prst': 'custGeom'}, None
382
+ return None, None
383
+ d = {'prst': pg.get('prst')}
384
+ adj = {}
385
+ for gd in pg.findall('a:avLst/a:gd', NS):
386
+ adj[gd.get('name')] = gd.get('fmla')
387
+ if adj:
388
+ d['adj'] = adj
389
+ radius = None
390
+ if d['prst'] in ('roundRect', 'round1Rect', 'round2SameRect', 'round2DiagRect',
391
+ 'snipRoundRect') and w_px and h_px:
392
+ fmla = adj.get('adj') or adj.get('adj1') or 'val 16667'
393
+ try:
394
+ val = float(str(fmla).split()[-1])
395
+ except (ValueError, IndexError):
396
+ val = 16667.0
397
+ radius = round(val / 100000.0 * min(w_px, h_px), 1)
398
+ return d, radius
399
+
400
+
401
+ # ------------------------------------------------------------------ text runs
402
+ def _lnspc(pPr, ctx):
403
+ if pPr is None:
404
+ return None
405
+ pct = pPr.find('a:lnSpc/a:spcPct', NS)
406
+ if pct is not None:
407
+ return {'mult': round(int(pct.get('val')) / 100000.0, 3)}
408
+ pts = pPr.find('a:lnSpc/a:spcPts', NS)
409
+ if pts is not None:
410
+ return {'px': ctx.units.pt100(pts.get('val'))}
411
+ return None
412
+
413
+
414
+ def _spc_before_after(pPr, ctx, tag):
415
+ if pPr is None:
416
+ return None
417
+ pct = pPr.find('a:%s/a:spcPct' % tag, NS)
418
+ if pct is not None:
419
+ return {'pct': round(int(pct.get('val')) / 1000.0, 2)}
420
+ pts = pPr.find('a:%s/a:spcPts' % tag, NS)
421
+ if pts is not None:
422
+ return {'px': ctx.units.pt100(pts.get('val'))}
423
+ return None
424
+
425
+
426
+ def font_weight(raw_name):
427
+ """Weight carried by the font *name*. `b="1"` is tracked separately (see
428
+ read_rpr's `bold`) because the name is the only source that distinguishes
429
+ Medium/SemiBold/DemiBold — collapsing both into one field loses the axis."""
430
+ return family_of(raw_name)[1] if raw_name else None
431
+
432
+
433
+ def family_of(raw):
434
+ """raw typeface -> (family, weight, italic). Weight is kept; only the
435
+ *family key* is stripped, so counting merges while 字重 stays readable."""
436
+ if not raw:
437
+ return None, None, False
438
+ name = raw.strip()
439
+ weight, italic = None, False
440
+ parts = name.replace('_', ' _').split(' ')
441
+ while len(parts) > 1:
442
+ tail = parts[-1].strip().lower().replace('-', '')
443
+ if tail in ITALIC_TOKENS:
444
+ italic = True
445
+ parts.pop()
446
+ continue
447
+ if tail in WEIGHT_TOKENS:
448
+ weight = WEIGHT_TOKENS[tail]
449
+ parts.pop()
450
+ continue
451
+ break
452
+ family = ' '.join(parts).replace(' _', '_').strip(' -')
453
+ return (family or name), weight, italic
454
+
455
+
456
+ def alias_group(family):
457
+ key = ''.join(ch for ch in family.lower() if ch.isalnum())
458
+ for grp, needles in FONT_ALIAS_GROUPS.items():
459
+ for n in needles:
460
+ nk = ''.join(ch for ch in n.lower() if ch.isalnum())
461
+ if nk and nk in key:
462
+ return grp
463
+ return None
464
+
465
+
466
+ def read_rpr(rPr, ctx):
467
+ d = {}
468
+ if rPr is None:
469
+ return d
470
+ if rPr.get('sz'):
471
+ d['sz_px'] = ctx.units.pt100(rPr.get('sz'))
472
+ if rPr.get('b') == '1':
473
+ d['bold'] = True
474
+ if rPr.get('i') == '1':
475
+ d['italic'] = True
476
+ if rPr.get('u') and rPr.get('u') != 'none':
477
+ d['underline'] = rPr.get('u')
478
+ if rPr.get('strike') and rPr.get('strike') != 'noStrike':
479
+ d['strike'] = rPr.get('strike')
480
+ if rPr.get('spc'):
481
+ d['spc_px'] = ctx.units.pt100(rPr.get('spc'), 2)
482
+ if rPr.get('cap') and rPr.get('cap') != 'none':
483
+ d['cap'] = rPr.get('cap')
484
+ if rPr.get('baseline') and rPr.get('baseline') != '0':
485
+ d['baseline'] = int(rPr.get('baseline')) / 1000.0
486
+ for tag in ('latin', 'ea', 'cs'):
487
+ e = rPr.find('a:%s' % tag, NS)
488
+ if e is not None and e.get('typeface'):
489
+ d[tag] = e.get('typeface')
490
+ f = describe_fill(rPr, ctx)
491
+ if f:
492
+ if f.get('type') == 'solid':
493
+ d['color'] = f.get('color')
494
+ else:
495
+ d['fill'] = f
496
+ ln = rPr.find('a:ln', NS)
497
+ if ln is not None:
498
+ d['outline'] = True
499
+ w = font_weight(d.get('latin') or d.get('ea'))
500
+ if w:
501
+ d['weight'] = w
502
+ return d
503
+
504
+
505
+ def read_txbody(tx, ctx, kind='txBody'):
506
+ """Paragraphs with per-run facts + the part-local lstStyle type scale."""
507
+ if tx is None:
508
+ return None
509
+ out = {}
510
+ bp = tx.find('a:bodyPr', NS)
511
+ if bp is not None:
512
+ b = {}
513
+ for k in ('anchor', 'anchorCtr', 'vert', 'wrap', 'rot'):
514
+ if bp.get(k):
515
+ b[k] = bp.get(k)
516
+ ins = {}
517
+ for k in ('lIns', 'tIns', 'rIns', 'bIns'):
518
+ if bp.get(k) is not None:
519
+ ins[k] = ctx.units.px(bp.get(k))
520
+ if ins:
521
+ b['insets_px'] = ins
522
+ if bp.find('a:normAutofit', NS) is not None:
523
+ b['autofit'] = 'norm'
524
+ elif bp.find('a:spAutoFit', NS) is not None:
525
+ b['autofit'] = 'shape'
526
+ if b:
527
+ out['bodyPr'] = b
528
+ ls = tx.find('a:lstStyle', NS)
529
+ if ls is not None and len(ls):
530
+ lvls = {}
531
+ for lvl in ls:
532
+ p = read_lvl(lvl, ctx)
533
+ if p:
534
+ lvls[local(lvl.tag)] = p
535
+ if lvls:
536
+ out['lstStyle'] = lvls
537
+ paras = []
538
+ for p in tx.findall('a:p', NS):
539
+ pPr = p.find('a:pPr', NS)
540
+ info = {}
541
+ if pPr is not None:
542
+ for k in ('algn', 'lvl', 'rtl'):
543
+ if pPr.get(k):
544
+ info[k] = pPr.get(k)
545
+ for k in ('marL', 'marR', 'indent'):
546
+ if pPr.get(k) is not None:
547
+ info[k + '_px'] = ctx.units.px(pPr.get(k))
548
+ v = _lnspc(pPr, ctx)
549
+ if v:
550
+ info['lnSpc'] = v
551
+ for tag in ('spcBef', 'spcAft'):
552
+ v = _spc_before_after(pPr, ctx, tag)
553
+ if v:
554
+ info[tag] = v
555
+ if pPr.find('a:buNone', NS) is not None:
556
+ info['bullet'] = 'none'
557
+ bc = pPr.find('a:buChar', NS)
558
+ if bc is not None:
559
+ info['bullet'] = bc.get('char')
560
+ ba = pPr.find('a:buAutoNum', NS)
561
+ if ba is not None:
562
+ info['bullet'] = 'auto:%s' % ba.get('type', '')
563
+ # Paragraph-level run defaults. Mac Office / Keynote exports put the
564
+ # size here rather than on a:rPr, so skipping it loses the majority
565
+ # of the declared type scale on those decks.
566
+ dr = pPr.find('a:defRPr', NS)
567
+ if dr is not None:
568
+ facts = read_rpr(dr, ctx)
569
+ if facts:
570
+ info['defRPr'] = facts
571
+ runs = []
572
+ for r in list(p.findall('a:r', NS)) + list(p.findall('a:fld', NS)):
573
+ t = r.find('a:t', NS)
574
+ rec = read_rpr(r.find('a:rPr', NS), ctx)
575
+ rec['text'] = (t.text or '') if t is not None else ''
576
+ if local(r.tag) == 'fld':
577
+ rec['field'] = r.get('type')
578
+ runs.append(rec)
579
+ if not runs:
580
+ ep = p.find('a:endParaRPr', NS)
581
+ if ep is not None:
582
+ rec = read_rpr(ep, ctx)
583
+ if rec:
584
+ rec['text'] = ''
585
+ rec['empty_para'] = True
586
+ runs.append(rec)
587
+ info['runs'] = runs
588
+ paras.append(info)
589
+ out['paragraphs'] = paras
590
+ return out
591
+
592
+
593
+ def read_lvl(lvl, ctx):
594
+ """lvlNpPr / titleStyle level -> declared paragraph+run defaults (direct read,
595
+ no inheritance resolution)."""
596
+ d = {}
597
+ for k in ('algn',):
598
+ if lvl.get(k):
599
+ d[k] = lvl.get(k)
600
+ for k in ('marL', 'indent', 'defTabSz'):
601
+ if lvl.get(k) is not None:
602
+ d[k + '_px'] = ctx.units.px(lvl.get(k))
603
+ v = _lnspc(lvl, ctx)
604
+ if v:
605
+ d['lnSpc'] = v
606
+ for tag in ('spcBef', 'spcAft'):
607
+ v = _spc_before_after(lvl, ctx, tag)
608
+ if v:
609
+ d[tag] = v
610
+ dr = lvl.find('a:defRPr', NS)
611
+ if dr is not None:
612
+ d.update(read_rpr(dr, ctx))
613
+ bc = lvl.find('a:buChar', NS)
614
+ if bc is not None:
615
+ d['bullet'] = bc.get('char')
616
+ if lvl.find('a:buNone', NS) is not None:
617
+ d['bullet'] = 'none'
618
+ return d
619
+
620
+
621
+ # --------------------------------------------------------------------- shapes
622
+ def group_transform(grpSp):
623
+ """(sx, sy, dx, dy) in EMU mapping a group's child coords into parent coords."""
624
+ x = grpSp.find('p:grpSpPr/a:xfrm', NS)
625
+ if x is None:
626
+ return 1.0, 1.0, 0.0, 0.0
627
+ off, ext = x.find('a:off', NS), x.find('a:ext', NS)
628
+ cOff, cExt = x.find('a:chOff', NS), x.find('a:chExt', NS)
629
+ if off is None or ext is None or cOff is None or cExt is None:
630
+ return 1.0, 1.0, 0.0, 0.0
631
+ ox, oy = int(off.get('x')), int(off.get('y'))
632
+ cx, cy = int(ext.get('cx')), int(ext.get('cy'))
633
+ kx, ky = int(cOff.get('x')), int(cOff.get('y'))
634
+ ex, ey = int(cExt.get('cx')), int(cExt.get('cy'))
635
+ sx = cx / float(ex) if ex else 1.0
636
+ sy = cy / float(ey) if ey else 1.0
637
+ return sx, sy, ox - kx * sx, oy - ky * sy
638
+
639
+
640
+ XFRM_PATHS = {
641
+ 'sp': 'p:spPr/a:xfrm', 'pic': 'p:spPr/a:xfrm', 'cxnSp': 'p:spPr/a:xfrm',
642
+ 'grpSp': 'p:grpSpPr/a:xfrm', 'graphicFrame': 'p:xfrm',
643
+ }
644
+
645
+
646
+ def read_xfrm(el, tag):
647
+ x = el.find(XFRM_PATHS.get(tag, 'p:spPr/a:xfrm'), NS)
648
+ if x is None:
649
+ return None
650
+ off, ext = x.find('a:off', NS), x.find('a:ext', NS)
651
+ if off is None or ext is None:
652
+ return None
653
+ return {
654
+ 'x': int(off.get('x')), 'y': int(off.get('y')),
655
+ 'cx': int(ext.get('cx')), 'cy': int(ext.get('cy')),
656
+ 'rot': int(x.get('rot', 0)) / 60000.0,
657
+ 'flipH': x.get('flipH') == '1', 'flipV': x.get('flipV') == '1',
658
+ }
659
+
660
+
661
+ def rotated_bbox(x, y, w, h, rot_deg):
662
+ """Axis-aligned bounding box of a box rotated about its centre."""
663
+ import math
664
+ if not rot_deg:
665
+ return x, y, w, h
666
+ a = math.radians(rot_deg)
667
+ c, s = abs(math.cos(a)), abs(math.sin(a))
668
+ nw, nh = w * c + h * s, w * s + h * c
669
+ return x + (w - nw) / 2.0, y + (h - nh) / 2.0, nw, nh
670
+
671
+
672
+ BLEED_TOL = 0.05
673
+
674
+
675
+ def classify_box(x, y, w, h, W, H):
676
+ """完全出界 -> drop / 出血 <=5% -> bleed / >5% -> clamp + gap (方案 v0.2 §3 难点 9).
677
+
678
+ Emptiness is an actual rectangle intersection, not a `w <= 0` test: rules and
679
+ connectors are legitimately zero-extent on one axis (a horizontal divider has
680
+ h = 0) and must stay in as design evidence.
681
+ """
682
+ if w < 0 or h < 0:
683
+ return 'outside', None, None
684
+ if max(x, 0.0) > min(x + w, float(W)) or max(y, 0.0) > min(y + h, float(H)):
685
+ return 'outside', None, None
686
+ over = {
687
+ 'left': max(0.0, -x) / W, 'right': max(0.0, x + w - W) / W,
688
+ 'top': max(0.0, -y) / H, 'bottom': max(0.0, y + h - H) / H,
689
+ }
690
+ worst = max(over.values())
691
+ if worst <= 0:
692
+ return 'inside', None, None
693
+ detail = {k: round(v * 100, 2) for k, v in over.items() if v > 0}
694
+ if worst <= BLEED_TOL:
695
+ return 'bleed', detail, None
696
+ cx0, cy0 = max(0.0, x), max(0.0, y)
697
+ cx1, cy1 = min(float(W), x + w), min(float(H), y + h)
698
+ return 'clamped', detail, (round(cx0, 1), round(cy0, 1),
699
+ round(cx1 - cx0, 1), round(cy1 - cy0, 1))