@lark-apaas/coding-steering 0.1.18-dev.21ea0ba → 0.1.18-dev.22911a7
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/package.json +1 -1
- package/steering/design-html/skills/pptx-style-extract/SKILL.md +57 -22
- package/steering/design-html/skills/pptx-style-extract/font-fallback.yaml +3 -3
- package/steering/design-html/skills/pptx-style-extract/scripts/census.py +18 -12
- package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +123 -8
- package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +1451 -227
- package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +325 -22
- package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +1 -1
- package/steering/design-html/skills/pptx-style-extract/scripts/package.py +254 -153
- package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +6 -3
- package/steering/design-html/skills/pptx-style-extract/scripts/query.py +2 -2
- package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +16 -10
- package/steering/design-html/skills/pptx-style-extract/scripts/test_background_composite.py +57 -0
- package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +17 -12
- package/steering/design-html/skills/slide-deck/SKILL.md +17 -3
- package/steering/design-html/skills/slide-deck/scripts/check_local_references.py +179 -0
|
@@ -279,18 +279,19 @@ def render_shape(shape, media_url, stats):
|
|
|
279
279
|
|
|
280
280
|
HEAD = """<style>
|
|
281
281
|
body{margin:0;background:#2b2b2b;font-family:%s}
|
|
282
|
-
.page{position:relative;width
|
|
282
|
+
.page{position:relative;width:%dpx;height:%dpx;overflow:hidden}
|
|
283
283
|
.sp{position:absolute;box-sizing:border-box}
|
|
284
284
|
.sp p{margin:0}
|
|
285
285
|
.ph{position:absolute;left:6px;top:4px;font:16px/1.2 monospace;color:#ff2b88;background:#fff9;padding:1px 4px}
|
|
286
286
|
</style>
|
|
287
|
-
"""
|
|
287
|
+
"""
|
|
288
288
|
|
|
289
|
+
# 缩略框跟着画布比例走,缩放比也一起算——写死 16:9 会把非 16:9 模板的页面裁掉一截
|
|
289
290
|
INDEX_EXTRA = """<style>
|
|
290
|
-
.wrap{width
|
|
291
|
+
.wrap{width:%dpx;margin:0 auto;padding:16px 0}
|
|
291
292
|
.lbl{color:#eee;font:13px/1.6 monospace;margin:14px 0 4px}
|
|
292
|
-
.box{width
|
|
293
|
-
.box .page{transform:scale(
|
|
293
|
+
.box{width:%dpx;height:%dpx;overflow:hidden;margin-bottom:6px}
|
|
294
|
+
.box .page{transform:scale(%g);transform-origin:top left}
|
|
294
295
|
</style>
|
|
295
296
|
"""
|
|
296
297
|
|
|
@@ -508,7 +509,8 @@ def render_pages_png(pages, outdir, data, scale=0.5):
|
|
|
508
509
|
for m in data.get('media') or [] if m.get('exported') and m.get('out')}
|
|
509
510
|
png_dir = os.path.join(outdir, 'ref', 'rebuild', 'png')
|
|
510
511
|
os.makedirs(png_dir, exist_ok=True)
|
|
511
|
-
|
|
512
|
+
cpx = ((data.get('canvas') or {}).get('px') or [1920, 1080])
|
|
513
|
+
W, H = int(cpx[0] * scale), int(cpx[1] * scale)
|
|
512
514
|
for kind, n, label, layers, default_color, page_bg in pages:
|
|
513
515
|
canvas = Image.new('RGBA', (W, H), _rgba(page_bg) or (136, 136, 136, 255))
|
|
514
516
|
g = _grad_stops(page_bg or '')
|
|
@@ -642,15 +644,19 @@ def main():
|
|
|
642
644
|
tx1_of_master.get(master, '#000000'),
|
|
643
645
|
layout_bg.get(part, '#888')))
|
|
644
646
|
|
|
645
|
-
if only is not None:
|
|
646
|
-
pages = [p for p in pages if p[
|
|
647
|
+
if only is not None: # 对样张和版式一律生效(form=3 的代表页是版式,不是样张)
|
|
648
|
+
pages = [p for p in pages if p[1] in only]
|
|
647
649
|
|
|
648
650
|
stats = {'shapes': 0, 'text': 0, 'img_ok': 0, 'img_missing': 0, 'no_box': 0}
|
|
649
|
-
|
|
651
|
+
cw, ch = ((data.get('canvas') or {}).get('px') or [1920, 1080])[:2]
|
|
652
|
+
head = HEAD % (CJK_STACK, cw, ch) # 视口跟画布走,非 16:9 模板不能按 1920x1080 裁
|
|
653
|
+
thumb_scale = 960.0 / cw
|
|
654
|
+
index = [head, INDEX_EXTRA % (960, 960, round(ch * thumb_scale), thumb_scale),
|
|
655
|
+
'<div class="wrap">']
|
|
650
656
|
for kind, n, label, layers, default_color, page_bg in (() if args.no_html else pages):
|
|
651
657
|
html = page_html(layers, default_color, page_bg, media_url, stats)
|
|
652
658
|
with open(os.path.join(rebuild, '%s-%d.html' % (kind, n)), 'w', encoding='utf-8') as f:
|
|
653
|
-
f.write(
|
|
659
|
+
f.write(head + html)
|
|
654
660
|
index += ['<div class="lbl">%s</div>' % esc(label),
|
|
655
661
|
'<div class="box">%s</div>' % html]
|
|
656
662
|
index.append('</div>')
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Focused regression tests for deterministic picture compositing."""
|
|
3
|
+
import io
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import unittest
|
|
7
|
+
|
|
8
|
+
from PIL import Image
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
11
|
+
|
|
12
|
+
from extract import _draw_picture # noqa: E402
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _Archive:
|
|
16
|
+
def __init__(self, media, raw):
|
|
17
|
+
self.names = {media}
|
|
18
|
+
self.zip = self
|
|
19
|
+
self.raw = raw
|
|
20
|
+
|
|
21
|
+
def read(self, media):
|
|
22
|
+
if media not in self.names:
|
|
23
|
+
raise KeyError(media)
|
|
24
|
+
return self.raw
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _png(pixels):
|
|
28
|
+
image = Image.new('RGBA', (len(pixels), 1))
|
|
29
|
+
image.putdata(pixels)
|
|
30
|
+
output = io.BytesIO()
|
|
31
|
+
image.save(output, 'PNG')
|
|
32
|
+
return output.getvalue()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BackgroundCompositeTest(unittest.TestCase):
|
|
36
|
+
def test_applies_flip_and_opacity_once(self):
|
|
37
|
+
media = 'ppt/media/source.png'
|
|
38
|
+
package = _Archive(media, _png([(255, 0, 0, 255), (0, 0, 255, 255)]))
|
|
39
|
+
canvas = Image.new('RGBA', (2, 1), (255, 255, 255, 255))
|
|
40
|
+
|
|
41
|
+
drawn = _draw_picture(canvas, {
|
|
42
|
+
'media': media,
|
|
43
|
+
'box_unrotated': {'x': 0, 'y': 0, 'w': 2, 'h': 1},
|
|
44
|
+
'flipH': True,
|
|
45
|
+
'opacity': 0.5,
|
|
46
|
+
}, package)
|
|
47
|
+
|
|
48
|
+
self.assertTrue(drawn)
|
|
49
|
+
left, right = canvas.getpixel((0, 0)), canvas.getpixel((1, 0))
|
|
50
|
+
self.assertEqual(left[:3], (128, 128, 255))
|
|
51
|
+
self.assertEqual(right[:3], (255, 128, 128))
|
|
52
|
+
self.assertEqual(left[3], 255)
|
|
53
|
+
self.assertEqual(right[3], 255)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == '__main__':
|
|
57
|
+
unittest.main()
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
- 消费方直接读 design.md,靠目录约定找 sidecar 与资产;frontmatter `path` 是唯一文件引用点。
|
|
18
18
|
- manifest(`manifest.json`,识别靠内部 `schemaVersion` 字面量不靠文件名)服务存储、索引、校验和迁移;消费模型不需要读它。
|
|
19
|
-
- `ref/`
|
|
19
|
+
- `ref/` 只放 `audit.yaml`(数值出处的人工复核记录,几 KB)。频次原表、聚类原始数据、`extract.json`、重建图、logo 候选图**留在抽取工作目录,不进交付包**——原设计是「下发时链路剥离」,但链路上没有环节真的做剥离,审计材料会连带进消费上下文并占掉包体的大头。
|
|
20
20
|
|
|
21
21
|
## 1. design.md frontmatter 新增键
|
|
22
22
|
|
|
@@ -53,10 +53,10 @@ canvas: 1920x1080 # layouts.md 首键;px = round(EMU / sldSz_cx * 1920)
|
|
|
53
53
|
themes: [dark, light]
|
|
54
54
|
default-theme: dark # 双主题包必填(V2-13)
|
|
55
55
|
colors: # v1 单层扁平,主题进 token 名
|
|
56
|
-
dark-surface: "#
|
|
57
|
-
dark-on-surface: "#
|
|
58
|
-
light-surface: "#
|
|
59
|
-
primary: "#
|
|
56
|
+
dark-surface: "#RRGGBB"
|
|
57
|
+
dark-on-surface: "#RRGGBB"
|
|
58
|
+
light-surface: "#RRGGBB"
|
|
59
|
+
primary: "#RRGGBB" # 共用色不加前缀
|
|
60
60
|
```
|
|
61
61
|
|
|
62
62
|
- token 前缀约定(`dark-X`/`light-X` = 主题专属,无前缀 = 共用)在 `## Usage` 里向消费者写一句;主题机制溯源(clrMap 反转等)落 `ref/audit.yaml`。
|
|
@@ -65,7 +65,7 @@ colors: # v1 单层扁平,主题进 token 名
|
|
|
65
65
|
|
|
66
66
|
### 1.3 排除色
|
|
67
67
|
|
|
68
|
-
- **排除色进 `## Hard Rules`
|
|
68
|
+
- **排除色进 `## Hard Rules` 带证据计数**,正向给替代(如「`<hex>` 为编辑器参考线色(出现 <N> 次),非设计色」)。频次原表与 color-confidence 证据进 `ref/`。
|
|
69
69
|
- 排除色断言必须以**解析后频次**为准,`styleRef` 主题兜底引用(不渲染)与真实设计用色分开。
|
|
70
70
|
|
|
71
71
|
## 2. `assets` 段
|
|
@@ -84,7 +84,7 @@ assets:
|
|
|
84
84
|
kind: background
|
|
85
85
|
role: cover # cover | content | section | closing | accent
|
|
86
86
|
theme: dark
|
|
87
|
-
recipe: "linear-gradient(
|
|
87
|
+
recipe: "linear-gradient(<angle>, <color> 0%, <color> 100%)" # 可选:CSS 重绘配方
|
|
88
88
|
bg-content-solid: # 纯色背景:无 path/url,引 colors token
|
|
89
89
|
kind: background
|
|
90
90
|
role: content
|
|
@@ -123,13 +123,16 @@ layouts:
|
|
|
123
123
|
themes: [dark, light] # 深浅孪生合并
|
|
124
124
|
background: {dark: bg-cover-dark, light: bg-cover-light}
|
|
125
125
|
slots:
|
|
126
|
-
- {role: title, box: [
|
|
127
|
-
- {role: logo, box: [
|
|
126
|
+
- {role: title, box: [<x>, <y>, <w>, <h>], type: title}
|
|
127
|
+
- {role: logo, box: [<x>, <y>, <w>, <h>], asset: {dark: logo-on-dark, light: logo-on-light}}
|
|
128
|
+
decor:
|
|
129
|
+
- {box: [<x>, <y>, <w>, <h>], geom: ellipse, css: "<CSS 声明串>"}
|
|
128
130
|
confidence: high
|
|
129
131
|
```
|
|
130
132
|
|
|
131
133
|
- **`background` 三形态**:`<asset-id>` / `{<theme>: <asset-id>}` / `{color: <colors-token>}`(`color` 是保留键,主题名禁止叫 color)。`asset` 两形态:`<asset-id>` / `{<theme>: <asset-id>}`。
|
|
132
134
|
- **背景安全扩展**:有真实背景图的 archetype 建议写 `text_safe: [x,y,w,h]`、`avoid: [{box: [x,y,w,h], reason: "..."}]`、`pairing_rule: "..."`。这些是消费约束,不参与封闭枚举;用于避免标题、正文、图表、卡片、表格、时间线及其容器外接矩形覆盖背景视觉主体、强光斑或深色透明区;透明容器也不能跨进禁放区。
|
|
135
|
+
- **`decor`(可选)**:这一页无文字的图形骨架——图标托底的圆、卡片、分隔线。每条 `{box, geom, css}`:`box` 定位,`css` 是可直接写进 style 的声明串,`geom` 取源形状的 prst(`ellipse` 另加 `border-radius: 50%`)。层级在背景之上、`slots` 之下;带 `asset` 的槽落在 decor 之上是版式本意,不算重叠。
|
|
133
136
|
- **`type` 封闭枚举**:`title | subtitle | body | pic | table | chart | media | slide-number | footer`。大数字/序号走 `type: title`,语义由 `role`(如 `big-number`)承担。
|
|
134
137
|
- **`slots.*.role` 开放不校验**(语义槽位):优先复用已知词表(OOXML ST_SlideLayoutType / Slidev 20 布局 / Google PredefinedLayout,如 big-number、caption、main-point),确无对应再自造。
|
|
135
138
|
- archetype ≤15(内联降级形态 ≤11);深浅孪生合并为一条;版式溯源/母版取舍进 `ref/`。
|
|
@@ -139,14 +142,14 @@ layouts:
|
|
|
139
142
|
|
|
140
143
|
```yaml
|
|
141
144
|
safe-area: # 开放命名 map,可多套边距体系
|
|
142
|
-
content: {top:
|
|
143
|
-
editorial: {left:
|
|
145
|
+
content: {top: <px>, right: <px>, bottom: <px>, left: <px>, applies-to: [content, quote]}
|
|
146
|
+
editorial: {left: <px>, right: <px>, applies-to: [cover, section, closing]}
|
|
144
147
|
confidence: medium
|
|
145
148
|
```
|
|
146
149
|
|
|
147
150
|
冲突裁决:`slots.box` 是实例真值,`safe-area` 是归纳框架,**以 slots.box 为准**。
|
|
148
151
|
|
|
149
|
-
## 5. check_v2 校验(
|
|
152
|
+
## 5. check_v2 校验(18 行:V2-1..V2-15 + V2-R5/R6/R7)
|
|
150
153
|
|
|
151
154
|
check_v1 全部规则原样生效。扫描范围 = 包目录,V2-1/V2-2 跨 design.md + layouts.md 求并集。
|
|
152
155
|
|
|
@@ -165,6 +168,8 @@ check_v1 全部规则原样生效。扫描范围 = 包目录,V2-1/V2-2 跨 des
|
|
|
165
168
|
| V2-11 | 键名命中 YAML 1.1 布尔字面量 | FAIL |
|
|
166
169
|
| V2-12 | `path`/`url`/`color` 恰好存在一个;`color` 仅 background 允许;`full` 仅可伴随 `path` | FAIL |
|
|
167
170
|
| V2-13 | 多主题(`themes` 长度 >1)缺 `default-theme`(只看 design.md frontmatter——冲突以 design.md 为准) | WARN |
|
|
171
|
+
| V2-14 | 版式的 `flow` 与 `slots` 互斥(同时出现 = FAIL);`flow.regions[].kind` 在 `grid`/`stack`/`free` 内,`grid` 必带 `cols` | FAIL |
|
|
172
|
+
| V2-15 | 同段字段自洽:`backgrounds.*` 的 `text_safe` 不得与任一 `avoid` 相交;两者形态须为 `[x, y, w, h]` 四个数且 w/h 为正 | FAIL |
|
|
168
173
|
| V2-R5 | sidecar frontmatter 重复 design.md 已有顶层键(`layouts` 载荷键与 `canvas` 除外——canvas 的家就在 sidecar) | WARN |
|
|
169
174
|
| V2-R6 | assets 条目出现审计字段(boxes/aspect/mark/confidence)或 design.md 顶层出现 canvas/canvas-source/theme-mechanism/color-confidence——应移 `ref/audit.yaml` / layouts.md | WARN |
|
|
170
175
|
| V2-R7 | 有 layouts sidecar 指针但正文未出现 `layouts.md` 字样(弱指针,消费者到不了版式数据) | WARN |
|
|
@@ -19,10 +19,9 @@ metadata:
|
|
|
19
19
|
|
|
20
20
|
## PPTX/POTX 模板附件前置步骤
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
如果对话里出现 `.pptx` 或 `.potx` 附件,并且当前任务是制作/生成/改做一个 PPT、演示文稿、slides、deck,**默认附件就是模板或视觉参考,先运行 `pptx-style-extract` skill**,读取产出的 `design.md`、`layouts.md` 和 `assets/`,再开始写 deck。不需要等用户再说“按这个模板”。
|
|
23
23
|
|
|
24
|
-
-
|
|
25
|
-
- 只总结、翻译、提取内容、审阅已有 PPTX 时,不需要抽取风格。
|
|
24
|
+
- 只有用户明确要求只总结、只翻译、只提取内容、只审阅或直接修改已有 PPTX 时,才跳过风格抽取。
|
|
26
25
|
- 不要用 `SummarizeAttachmentOrFile` 的 Markdown/文本摘要替代风格抽取;文本摘要不包含母版、色板、字体、版式坐标和素材角色。
|
|
27
26
|
|
|
28
27
|
## 动手前先问
|
|
@@ -144,6 +143,21 @@ deck-stage 组件会对每个 slotted 子元素做绝对定位——**绝不**
|
|
|
144
143
|
5. **把这套 token 当成每页的内容预算**:在上述数值下,一页正文区大约容纳 14 行正文、或 6 个两行 bullet——在 scratchpad 排内容时就按预算裁剪,而不是写完再看塞不塞得下。装不下的处置顺序是**拆页 > 删内容 > 换更省空间的版式**;缩小字号是最后手段,且绝不越过 24px 下限——靠缩字塞进去的页,只是把溢出换成了后排看不清。反过来,内容远少于预算的页按「视觉平衡」的出路增密或合并,而不是放大字号去撑面积。
|
|
145
144
|
6. 构建幻灯片,牢记每张幻灯片既是设计练习也是文案练习。在版式、文字内容和语调方面给予每张幻灯片应有的关注。遵循上述原则,确保每张幻灯片能独立成立;一个只看这一页的人,应当无需其他上下文就能理解其高层含义。
|
|
146
145
|
|
|
146
|
+
## 提交前资源完整性门禁
|
|
147
|
+
|
|
148
|
+
最终一次写入 HTML/CSS 后、调用 `run_commit` 前,必须从项目根目录运行:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
python3 <本skill目录>/scripts/check_local_references.py index.html
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
把 `<本skill目录>` 换成本 skill 的实际所在目录。脚本从最终 `index.html` 出发,递归检查 HTML/CSS 引用的每个项目内脚本、样式、图片、字体等文件是否真实存在;外部 URL 不做离线探测。
|
|
155
|
+
|
|
156
|
+
- 只有输出 `RESOURCE_CHECK: PASS` 才能提交。
|
|
157
|
+
- 输出 `RESOURCE_CHECK: FAIL` 时,先重新复制或修正列出的项目内文件,再原样重跑;不得删除引用来掩盖仍在使用的资源。
|
|
158
|
+
- 该检查针对最终工作区状态。较早执行过复制命令或检查,不能证明最终提交完整。
|
|
159
|
+
- `run_commit` 的静态检查跳过参数不能替代本门禁,也不能用来绕过失败;提交时必须包含检查通过所依赖的全部项目内文件。
|
|
160
|
+
|
|
147
161
|
## 验证要点
|
|
148
162
|
|
|
149
163
|
审阅时,用幻灯片构图规则——而非网页布局直觉——来检查版面。底部留白是不是缺陷,用「留白 ≠ 空洞」的归属判据:内容自身完整、下方是无边框的整块呼吸空间,这是正确的幻灯片构图——不要出于网页直觉把 `flex-start` 改成 `center`;空白被元素边界圈占的,是被动空洞,按「视觉平衡」的出路修。
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
from html.parser import HTMLParser
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from urllib.parse import unquote, urlsplit
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
CSS_URL_PATTERN = re.compile(r"""url\(\s*(['"]?)(.*?)\1\s*\)""", re.IGNORECASE)
|
|
11
|
+
CSS_IMPORT_PATTERN = re.compile(
|
|
12
|
+
r"""@import\s+(?:url\(\s*)?(['"])(.*?)\1\s*\)?""",
|
|
13
|
+
re.IGNORECASE,
|
|
14
|
+
)
|
|
15
|
+
CSS_COMMENT_PATTERN = re.compile(r"/\*.*?\*/", re.DOTALL)
|
|
16
|
+
HREF_RESOURCE_TAGS = {"image", "link", "use"}
|
|
17
|
+
SRC_RESOURCE_TAGS = {
|
|
18
|
+
"audio",
|
|
19
|
+
"embed",
|
|
20
|
+
"iframe",
|
|
21
|
+
"img",
|
|
22
|
+
"input",
|
|
23
|
+
"script",
|
|
24
|
+
"source",
|
|
25
|
+
"track",
|
|
26
|
+
"video",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ReferenceParser(HTMLParser):
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
super().__init__()
|
|
33
|
+
self.references: set[str] = set()
|
|
34
|
+
self.in_style = False
|
|
35
|
+
|
|
36
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
37
|
+
tag = tag.lower()
|
|
38
|
+
if tag == "style":
|
|
39
|
+
self.in_style = True
|
|
40
|
+
attributes = dict(attrs)
|
|
41
|
+
if tag in HREF_RESOURCE_TAGS and attributes.get("href"):
|
|
42
|
+
self.references.add(attributes["href"])
|
|
43
|
+
if tag in SRC_RESOURCE_TAGS and attributes.get("src"):
|
|
44
|
+
self.references.add(attributes["src"])
|
|
45
|
+
if tag == "object" and attributes.get("data"):
|
|
46
|
+
self.references.add(attributes["data"])
|
|
47
|
+
if tag == "video" and attributes.get("poster"):
|
|
48
|
+
self.references.add(attributes["poster"])
|
|
49
|
+
|
|
50
|
+
srcset = attributes.get("srcset")
|
|
51
|
+
if tag in {"img", "source"} and srcset:
|
|
52
|
+
self.references.update(
|
|
53
|
+
candidate.strip().split()[0]
|
|
54
|
+
for candidate in srcset.split(",")
|
|
55
|
+
if candidate.strip()
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
style = attributes.get("style")
|
|
59
|
+
if style:
|
|
60
|
+
self.references.update(css_references(style))
|
|
61
|
+
|
|
62
|
+
def handle_endtag(self, tag: str) -> None:
|
|
63
|
+
if tag.lower() == "style":
|
|
64
|
+
self.in_style = False
|
|
65
|
+
|
|
66
|
+
def handle_data(self, data: str) -> None:
|
|
67
|
+
if self.in_style:
|
|
68
|
+
self.references.update(css_references(data))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def css_references(content: str) -> set[str]:
|
|
72
|
+
content_without_comments = CSS_COMMENT_PATTERN.sub("", content)
|
|
73
|
+
references = {
|
|
74
|
+
match.group(2).strip() for match in CSS_URL_PATTERN.finditer(content_without_comments)
|
|
75
|
+
}
|
|
76
|
+
references.update(
|
|
77
|
+
match.group(2).strip() for match in CSS_IMPORT_PATTERN.finditer(content_without_comments)
|
|
78
|
+
)
|
|
79
|
+
return references
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def resolve_local_reference(
|
|
83
|
+
raw_reference: str,
|
|
84
|
+
source: Path,
|
|
85
|
+
project_root: Path,
|
|
86
|
+
) -> Path | None:
|
|
87
|
+
reference = raw_reference.strip()
|
|
88
|
+
parsed = urlsplit(reference)
|
|
89
|
+
if (
|
|
90
|
+
not reference
|
|
91
|
+
or reference.startswith(("#", "//"))
|
|
92
|
+
or (parsed.scheme and parsed.scheme.lower() != "file")
|
|
93
|
+
or parsed.netloc
|
|
94
|
+
):
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
path_text = unquote(parsed.path)
|
|
98
|
+
if not path_text:
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
if parsed.scheme.lower() == "file":
|
|
102
|
+
return Path(path_text).resolve()
|
|
103
|
+
if path_text.startswith("/"):
|
|
104
|
+
return (project_root / path_text.lstrip("/")).resolve()
|
|
105
|
+
return (source.parent / path_text).resolve()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def collect_references(entry: Path, project_root: Path) -> list[tuple[Path, str, Path]]:
|
|
109
|
+
pending = [entry]
|
|
110
|
+
visited: set[Path] = set()
|
|
111
|
+
local_references: list[tuple[Path, str, Path]] = []
|
|
112
|
+
|
|
113
|
+
while pending:
|
|
114
|
+
source = pending.pop()
|
|
115
|
+
if source in visited or not source.is_file():
|
|
116
|
+
continue
|
|
117
|
+
visited.add(source)
|
|
118
|
+
|
|
119
|
+
content = source.read_text(encoding="utf-8")
|
|
120
|
+
if source.suffix.lower() == ".css":
|
|
121
|
+
references = css_references(content)
|
|
122
|
+
else:
|
|
123
|
+
parser = ReferenceParser()
|
|
124
|
+
parser.feed(content)
|
|
125
|
+
references = parser.references
|
|
126
|
+
|
|
127
|
+
for raw_reference in sorted(references):
|
|
128
|
+
target = resolve_local_reference(raw_reference, source, project_root)
|
|
129
|
+
if target is None:
|
|
130
|
+
continue
|
|
131
|
+
local_references.append((source, raw_reference, target))
|
|
132
|
+
if target.suffix.lower() == ".css" and target.is_file():
|
|
133
|
+
pending.append(target)
|
|
134
|
+
|
|
135
|
+
return local_references
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def display_path(path: Path, project_root: Path) -> str:
|
|
139
|
+
try:
|
|
140
|
+
return path.relative_to(project_root).as_posix()
|
|
141
|
+
except ValueError:
|
|
142
|
+
return str(path)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def main() -> int:
|
|
146
|
+
if len(sys.argv) != 2:
|
|
147
|
+
print("usage: check_local_references.py <entry.html>", file=sys.stderr)
|
|
148
|
+
return 2
|
|
149
|
+
|
|
150
|
+
project_root = Path.cwd().resolve()
|
|
151
|
+
entry = (project_root / sys.argv[1]).resolve()
|
|
152
|
+
if not entry.is_file():
|
|
153
|
+
print(f"RESOURCE_CHECK: FAIL entryNotFound={sys.argv[1]}")
|
|
154
|
+
return 1
|
|
155
|
+
|
|
156
|
+
missing: list[tuple[Path, str, Path]] = []
|
|
157
|
+
for source, raw_reference, target in collect_references(entry, project_root):
|
|
158
|
+
try:
|
|
159
|
+
target.relative_to(project_root)
|
|
160
|
+
except ValueError:
|
|
161
|
+
missing.append((source, raw_reference, target))
|
|
162
|
+
continue
|
|
163
|
+
if not target.is_file():
|
|
164
|
+
missing.append((source, raw_reference, target))
|
|
165
|
+
|
|
166
|
+
if not missing:
|
|
167
|
+
print("RESOURCE_CHECK: PASS")
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
print(f"RESOURCE_CHECK: FAIL missingLocalReferences={len(missing)}")
|
|
171
|
+
for source, raw_reference, target in missing:
|
|
172
|
+
source_name = display_path(source, project_root)
|
|
173
|
+
target_name = display_path(target, project_root)
|
|
174
|
+
print(f"- {source_name}: {raw_reference} -> {target_name}")
|
|
175
|
+
return 1
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
if __name__ == "__main__":
|
|
179
|
+
raise SystemExit(main())
|