agentcss 0.27.0 → 0.27.1
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/01-tokens/page.css +11 -0
- package/03-patterns/tabs/tabs.css +9 -0
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +23 -0
- package/README.md +5 -4
- package/README.zh-CN.md +16 -12
- package/START-HERE.md +1 -1
- package/ai/START-HERE.md +1 -1
- package/ai/cdp.js +152 -0
- package/ai/cli.js +22 -4
- package/ai/components.json +1 -1
- package/ai/pdf-renderer.js +177 -0
- package/ai/pdf.js +124 -79
- package/ai/shot.js +1 -109
- package/dist/01-tokens/page.css +1 -1
- package/dist/03-patterns/tabs/tabs.css +1 -1
- package/dist/COSTS.md +2 -2
- package/docs/ACCESSIBILITY.md +1 -1
- package/docs/PRINT.md +38 -10
- package/index.html +1 -1
- package/llms-full.txt +4 -3
- package/llms.txt +3 -1
- package/package.json +1 -1
package/01-tokens/page.css
CHANGED
|
@@ -86,6 +86,17 @@
|
|
|
86
86
|
max(env(safe-area-inset-left, 0px), var(--sp-4));
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/* 纸面视口高度不等于一屏,屏幕安全区与末尾留白也不属于正文。
|
|
90
|
+
实测长金融报告:仅去掉 padding-block-end,8 页(末页全空白)→ 7 页,
|
|
91
|
+
3689 个非空白字符保持不变;只去掉首端内边距或 min-height 都不能修它。
|
|
92
|
+
保留首端留白与左右内边距,避免顺手改变整份文档的分页位置。 */
|
|
93
|
+
@media print {
|
|
94
|
+
.page {
|
|
95
|
+
min-height: 0;
|
|
96
|
+
padding-block-end: 0;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
89
100
|
/* ============================================================================
|
|
90
101
|
1b. 盒模型(`border-box`)—— 只在 .page 范围内生效
|
|
91
102
|
----------------------------------------------------------------------------
|
package/AGENTS.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
- **Contract (machine-readable):** `ai/components.json` — 40 components with root class, slots, variants, states, ARIA, file paths, and **`printPolicy`** (what happens to this component on paper: `omit` / `expand` / `reveal` / `keep-together` / `allow-break`, plus a `pagination` flag). Query it; never guess class names — and never assume a component prints as it looks on screen.
|
|
4
4
|
- **Known failure modes:** `ai/pitfalls.json`, or `npx agentcss ai pitfalls --json --component=<id>`.
|
|
5
|
-
- **Print / PDF:** `npx agentcss ai pdf <page.html> --out page.pdf [--paper=a4|letter|
|
|
5
|
+
- **Print / PDF:** `npx agentcss ai pdf <page.html> --out page.pdf [--paper=a3|a4|a5|letter|legal] [--margin="18mm 15mm"] [--max-pages=N] [--wait-ms=800] [--ready-selector="#report-ready"]`. Wait for load + fonts; use a visible ready selector for asynchronous content. A timeout or browser failure never replaces an existing PDF. `@page` belongs to the exporter, never to the library. For paper validation use `ai check <doc.json> --medium=paper` — it requires `pageBudget` and refuses `pagination:"server"` components.
|
|
6
6
|
- **Query:** `npx agentcss ai components --json [--maturity=stable]` · `ai tokens --json [--set=dark]` · `ai tree --json`.
|
|
7
7
|
- **Validate, then branch on the exit code:** `npx agentcss ai validate <file> --schema=components --json`. Every subcommand accepts `--json`; failures print exactly one document `{"ok":false,"command","error":{"kind","message"}}`. Codes are one table for the whole CLI: `0` pass · `1` what you checked is bad (a verdict) · `2` bad flag or value — unknown flag, unknown enum value, empty value and a repeated flag all land here · `3` environment missing (no browser / no `node`) · `4` render failed · `5` over budget · `6` tool bug. A typo can never degrade into "empty result, exit 0".
|
|
8
8
|
- **Check the page you actually wrote:** `npx agentcss ai check <page.html> [--recipe=report/finance]`. Static — no network, no browser. It reports class names the library does not define, stylesheets that were never linked (naming the file that defines the class), missing `img alt` / table name / `th scope`, skipped heading levels, and the per-layout obligations in `ai/recipes.json`. `npx agentcss ai scaffold <recipe> --out <page.html>` gives a minimal shell that already passes.
|
package/CHANGELOG.md
CHANGED
|
@@ -18,6 +18,29 @@
|
|
|
18
18
|
|
|
19
19
|
## [Unreleased]
|
|
20
20
|
|
|
21
|
+
## [0.27.1] - 2026-11-06
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- **`ai pdf` 有了就绪契约:`--wait-ms`(0–60000,默认 800)与
|
|
26
|
+
`--ready-selector=<CSS>`。** 导出顺序固定为:页面 `load` → `document.fonts.ready`
|
|
27
|
+
→ 可选可见元素 → 额外等待 → 固定动画终态 → `Page.printToPDF`。
|
|
28
|
+
选择器在打印媒体下必须可见且有尺寸 —— 只存在但 `display:none` 不算就绪。
|
|
29
|
+
整个导出默认 60 秒总预算,超时是明确失败(退出码 4),不是"等一下大概就好了"。
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- **PDF 导出不再把旧文件当成本次成功。** 导出改走共享零依赖 CDP 管道
|
|
34
|
+
(`ai/cdp.js`,截图与 PDF 共用)。浏览器只写暂存产物,先验 `%PDF-` 文件头与页树,
|
|
35
|
+
再原子替换最终文件;浏览器失败、超时、非法参数或暂存产物无效时,已有 PDF
|
|
36
|
+
逐字节保留,结论明确为失败。未捕获 JS 异常随 `jsErrors` 返回。
|
|
37
|
+
超页数预算仍是另一种结果:保留完整的新 PDF 并返回 `over-budget`,绝不静默截断。
|
|
38
|
+
- **纸张不再吞下淡入面板或单独多出空白尾页。** Tabs 在 print 媒体下取消动画/过渡
|
|
39
|
+
并让 `opacity` 落到 1;`.page` 在 print 媒体下去掉屏幕最小高度与末尾内边距。
|
|
40
|
+
真实金融长文由 8 页(两页空白)修为 7 页,且每页都有文字。新增延迟内容、旧文件哈希
|
|
41
|
+
保护、有界超时与真实长文逐页内容、崩溃结论形状与临时文件清理判据(E14–E20)。
|
|
42
|
+
- **修正 AI 入口文档漂移。** 暗色查询统一为 `--set=dark`;区分 `check` 的 HTML/JSON
|
|
43
|
+
输入与 `diff` 的 JSON 输入;零模型 Benchmark 记录明确为 `3 / UNVERIFIED`。
|
|
21
44
|
|
|
22
45
|
## [0.27.0] - 2026-11-06
|
|
23
46
|
|
package/README.md
CHANGED
|
@@ -333,10 +333,11 @@ Every component carries a contract covering focus order, keyboard operation, ARI
|
|
|
333
333
|
|
|
334
334
|
```bash
|
|
335
335
|
npm ci
|
|
336
|
-
npm
|
|
337
|
-
npm run gate:
|
|
338
|
-
npm
|
|
339
|
-
npm run
|
|
336
|
+
npm test # unit and behaviour-core tests
|
|
337
|
+
npm run gate:pre-commit # ~7s: the five checks a one-line change can break
|
|
338
|
+
npm run gate:fast # static layer, no browser needed
|
|
339
|
+
npm run gate # full run: browsers, visual baselines, pack closure
|
|
340
|
+
npm run dist # rebuild dist/
|
|
340
341
|
```
|
|
341
342
|
|
|
342
343
|
## License
|
package/README.zh-CN.md
CHANGED
|
@@ -421,7 +421,7 @@ python -m http.server 8000 # 在仓库根起静态服务
|
|
|
421
421
|
|
|
422
422
|
```bash
|
|
423
423
|
python -m http.server 8000 & # 🔴 必须先起静态服务:一部分门禁要真浏览器渲染
|
|
424
|
-
bash 05-audit/check-all.sh #
|
|
424
|
+
bash 05-audit/check-all.sh # 全量门禁;条数以本轮输出为准,本机约 10 分钟
|
|
425
425
|
```
|
|
426
426
|
|
|
427
427
|
每一项的输出是 `PASS` / `FAIL` / `SKIP`,末尾给出失败条数与退出码(全绿为 0)。
|
|
@@ -518,14 +518,15 @@ bash 05-audit/check-all.sh # 全量门禁约 130 项,本机约 8 分钟
|
|
|
518
518
|
|
|
519
519
|
```bash
|
|
520
520
|
node ai/cli.js components --maturity=stable # 列出达到 stable 的组件
|
|
521
|
-
node ai/cli.js tokens --
|
|
522
|
-
node ai/cli.js check doc.json #
|
|
521
|
+
node ai/cli.js tokens --set=dark # 列出暗色令牌
|
|
522
|
+
node ai/cli.js check doc.json # 校验契约计划(读 JSON)
|
|
523
|
+
node ai/cli.js check page.html # 静态检查真实页面(读 HTML)
|
|
523
524
|
node ai/cli.js diff a.json b.json # 两份契约文档按语义 id 比对
|
|
524
525
|
```
|
|
525
526
|
|
|
526
|
-
>
|
|
527
|
-
>
|
|
528
|
-
>
|
|
527
|
+
> `check` 按计划与产物分流:JSON 查节点、组件档位和打印义务;HTML 查类名、
|
|
528
|
+
> 样式表链接与页面结构。两者都是静态检查,不启动浏览器。`diff` 只吃 JSON;
|
|
529
|
+
> 真浏览器的渲染回归由 `npm run gate` 负责。
|
|
529
530
|
|
|
530
531
|
- `ai/components.json` / `ai/tokens.json` 由源码**生成**(`--check` 已接门禁)
|
|
531
532
|
⇒ 改了源码忘记重新生成就红。真值只有一份,不靠"记得同步"。
|
|
@@ -556,16 +557,19 @@ node ai/cli.js diff a.json b.json # 两份契约文档按语义 id
|
|
|
556
557
|
## 开发
|
|
557
558
|
|
|
558
559
|
```bash
|
|
559
|
-
npm ci
|
|
560
|
-
npm test
|
|
561
|
-
npm run test:types
|
|
562
|
-
npm run gate:
|
|
563
|
-
npm run gate
|
|
560
|
+
npm ci # 按锁文件安装
|
|
561
|
+
npm test # 单元测试(约 1 秒)
|
|
562
|
+
npm run test:types # 类型契约:正例必过 + 反例必挂
|
|
563
|
+
npm run gate:pre-commit # 提交前档:5 道最容易被一行改动打破的判据(约 7 秒)
|
|
564
|
+
npm run gate:fast # 静态层:不装浏览器也能跑(约 3.5 分钟)
|
|
565
|
+
npm run gate # 全量:含浏览器、视觉基线与打包闭包(约 10 分钟)
|
|
564
566
|
npm run ai -- check path/to/doc.json # 机器可读契约的命令行(读 JSON 文档)
|
|
565
567
|
npm run benchmark # 兼容性基准
|
|
566
568
|
```
|
|
567
569
|
|
|
568
|
-
|
|
570
|
+
三档是**递进**关系,不是三选一:改代码时 `gate:pre-commit` 随手跑,
|
|
571
|
+
推送前 `gate:fast`,发版前 `gate`。**提交前档绿 ≠ 可发版** ——
|
|
572
|
+
它只覆盖"改一行就可能破"的那几道。
|
|
569
573
|
|
|
570
574
|
类型声明在 `types/index.d.ts`。改动它之前先看 `API.md` 的「稳定程度约定」。
|
|
571
575
|
|
package/START-HERE.md
CHANGED
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
| 进度条 / 环 | `02-primitives/progress/progress.css` | 228 | — | 有耗时过程要给进度就要 |
|
|
48
48
|
| 浮层气泡 | `02-primitives/popover/popover.css` | 187 | **必需 345** (`popover.js`) | 要一段信息贴着触发元素浮出来就要 |
|
|
49
49
|
| 日期区间 | `02-primitives/date-range/date-range.css` | 206 | **必需 332** (`date-range.js`) | 要选起止日期就要 |
|
|
50
|
-
| 标签页 | `03-patterns/tabs/tabs.css` |
|
|
50
|
+
| 标签页 | `03-patterns/tabs/tabs.css` | 181 | **必需 316** (`tabs.js`) | 同层级内容切换就要 |
|
|
51
51
|
| 折叠面板 | `03-patterns/accordion/accordion.css` | 453 | **必需 389** (`accordion.js`) | 长表单分段就要 |
|
|
52
52
|
| 动作菜单 | `03-patterns/dropdown/dropdown.css` | 135 | **必需 575** (`dropdown.js`) | 要一组动作(新建 / 导出 / 删除)就要 |
|
|
53
53
|
| 文字提示 | `03-patterns/tooltip/tooltip.css` | 89 | **必需 299** (`tooltip.js`) | 图标按钮或缩写需要一句话解释就要 |
|
package/ai/START-HERE.md
CHANGED
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
| 组件清单与成熟度 | `node ai/cli.js components --maturity=stable` |
|
|
59
59
|
| 某个组件踩过的坑 | `node ai/cli.js pitfalls --component=<id>`(数据在 `ai/pitfalls.json`) |
|
|
60
60
|
| 写文案 / 做演示 / 发页面时的坑 | `node ai/cli.js pitfalls --scope=writing`(数据在 `ai/pitfalls-writing.json`,不挂组件) |
|
|
61
|
-
| 令牌名与取值 | `node ai/cli.js tokens --
|
|
61
|
+
| 令牌名与取值 | `node ai/cli.js tokens --set=dark`、`node ai/cli.js tree`(分层视图) |
|
|
62
62
|
| 组件真实长什么样 | 该组件目录下的 `demo.html` |
|
|
63
63
|
| 英文一页速览 | `llms.txt` / `llms-full.txt`(发现层;本文件是中文详版) |
|
|
64
64
|
| 页面怎么搭 | `04-recipes/` 下的示例;要写报告/长文就先 `node ai/cli.js scaffold list` |
|
package/ai/cdp.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* cdp.js — 零依赖 Chrome DevTools Protocol 管道客户端。
|
|
3
|
+
*
|
|
4
|
+
* shot 与 pdf 共用这一份实现。调试走 fd 3/4,以 NUL 分隔 JSON;不开放端口,
|
|
5
|
+
* 不连接使用者的浏览器,也不引入 Puppeteer 运行时依赖。
|
|
6
|
+
*/
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
var { spawn } = require('child_process');
|
|
10
|
+
|
|
11
|
+
/** 启动一个隔离的 headless Chrome,并返回有界的 CDP 请求接口。 */
|
|
12
|
+
function connect(chromePath, profile) {
|
|
13
|
+
var args = [
|
|
14
|
+
'--headless=new',
|
|
15
|
+
'--disable-gpu',
|
|
16
|
+
'--disable-extensions',
|
|
17
|
+
'--no-first-run',
|
|
18
|
+
'--no-default-browser-check',
|
|
19
|
+
'--hide-scrollbars',
|
|
20
|
+
'--remote-debugging-pipe',
|
|
21
|
+
'--user-data-dir=' + profile,
|
|
22
|
+
'about:blank'
|
|
23
|
+
];
|
|
24
|
+
/* 只有明确以 root 在容器中运行时才允许调用方关闭沙箱。 */
|
|
25
|
+
if (process.env.AGENTCSS_NO_SANDBOX === '1') args.unshift('--no-sandbox');
|
|
26
|
+
var child = spawn(chromePath, args, {
|
|
27
|
+
stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'],
|
|
28
|
+
windowsHide: true
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
var buf = Buffer.alloc(0);
|
|
32
|
+
var pending = Object.create(null);
|
|
33
|
+
var waiters = [];
|
|
34
|
+
var listeners = [];
|
|
35
|
+
var nextId = 0;
|
|
36
|
+
var dead = null;
|
|
37
|
+
var stderr = '';
|
|
38
|
+
|
|
39
|
+
function failAll(err) {
|
|
40
|
+
dead = dead || err;
|
|
41
|
+
Object.keys(pending).forEach(function (k) {
|
|
42
|
+
clearTimeout(pending[k].timer);
|
|
43
|
+
pending[k].reject(dead);
|
|
44
|
+
});
|
|
45
|
+
pending = Object.create(null);
|
|
46
|
+
waiters.splice(0).forEach(function (w) {
|
|
47
|
+
clearTimeout(w.timer);
|
|
48
|
+
w.reject(dead);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
var commandPipe = child.stdio[3];
|
|
53
|
+
var eventPipe = child.stdio[4];
|
|
54
|
+
if (!commandPipe || !eventPipe) {
|
|
55
|
+
failAll(new Error('无法建立 CDP 管道(Chrome 没能启动)'));
|
|
56
|
+
}
|
|
57
|
+
/* 🔴 必须有 error 监听。浏览器起不来时管道会以 EPIPE/ECONNRESET 结束,
|
|
58
|
+
而 Node 的流在没有 error 监听时**直接抛出** —— 那就成了"本工具崩了",
|
|
59
|
+
和"这份 PDF 没导出来"是两件事,也让调用方看不到结构化结论。 */
|
|
60
|
+
if (commandPipe) commandPipe.on('error', function (e) { failAll(e); });
|
|
61
|
+
if (eventPipe) eventPipe.on('error', function (e) { failAll(e); });
|
|
62
|
+
|
|
63
|
+
function dispatch(msg) {
|
|
64
|
+
if (msg.id !== undefined && pending[msg.id]) {
|
|
65
|
+
var p = pending[msg.id];
|
|
66
|
+
delete pending[msg.id];
|
|
67
|
+
clearTimeout(p.timer);
|
|
68
|
+
if (msg.error) p.reject(new Error(p.method + ':' + (msg.error.message || JSON.stringify(msg.error))));
|
|
69
|
+
else p.resolve(msg.result);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (!msg.method) return;
|
|
73
|
+
for (var L = 0; L < listeners.length; L++) {
|
|
74
|
+
if (listeners[L].method === msg.method) listeners[L].fn(msg.params, msg.sessionId);
|
|
75
|
+
}
|
|
76
|
+
for (var i = waiters.length - 1; i >= 0; i--) {
|
|
77
|
+
var w = waiters[i];
|
|
78
|
+
if (w.method === msg.method && (!w.sessionId || w.sessionId === msg.sessionId)) {
|
|
79
|
+
waiters.splice(i, 1);
|
|
80
|
+
clearTimeout(w.timer);
|
|
81
|
+
w.resolve(msg.params);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (eventPipe) eventPipe.on('data', function (chunk) {
|
|
87
|
+
buf = Buffer.concat([buf, chunk]);
|
|
88
|
+
var i;
|
|
89
|
+
while ((i = buf.indexOf(0)) >= 0) {
|
|
90
|
+
var raw = buf.subarray(0, i).toString('utf8');
|
|
91
|
+
buf = buf.subarray(i + 1);
|
|
92
|
+
try { dispatch(JSON.parse(raw)); } catch (e) { /* 忽略不完整的非 JSON 浏览器输出 */ }
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
child.stdio[1].on('data', function () { /* Chrome 常规 stdout 不属于协议 */ });
|
|
96
|
+
child.stdio[2].on('data', function (chunk) {
|
|
97
|
+
stderr = (stderr + chunk.toString('utf8')).slice(-4000);
|
|
98
|
+
});
|
|
99
|
+
child.on('error', function (e) { failAll(e); });
|
|
100
|
+
child.on('exit', function (code) {
|
|
101
|
+
failAll(new Error('Chrome 提前退出(退出码 ' + code + ')'));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
function send(method, params, sessionId, timeoutMs) {
|
|
105
|
+
return new Promise(function (resolve, reject) {
|
|
106
|
+
if (dead) return reject(dead);
|
|
107
|
+
var id = ++nextId;
|
|
108
|
+
var limit = timeoutMs || 15000;
|
|
109
|
+
var timer = setTimeout(function () {
|
|
110
|
+
delete pending[id];
|
|
111
|
+
reject(new Error(method + ' 超时(' + limit + 'ms 没有回应)'));
|
|
112
|
+
}, limit);
|
|
113
|
+
pending[id] = { resolve: resolve, reject: reject, timer: timer, method: method };
|
|
114
|
+
try {
|
|
115
|
+
var message = { id: id, method: method, params: params || {} };
|
|
116
|
+
/* 顶层命令不能带 sessionId:null;Chrome 只接受“字段不存在”或字符串。 */
|
|
117
|
+
if (sessionId !== undefined && sessionId !== null) message.sessionId = sessionId;
|
|
118
|
+
commandPipe.write(JSON.stringify(message) + '\0');
|
|
119
|
+
} catch (e) {
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
delete pending[id];
|
|
122
|
+
reject(e);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function waitEvent(method, sessionId, timeoutMs) {
|
|
128
|
+
return new Promise(function (resolve, reject) {
|
|
129
|
+
var limit = timeoutMs || 15000;
|
|
130
|
+
var timer = setTimeout(function () {
|
|
131
|
+
var at = waiters.findIndex(function (w) {
|
|
132
|
+
return w.method === method && w.sessionId === sessionId;
|
|
133
|
+
});
|
|
134
|
+
if (at >= 0) waiters.splice(at, 1);
|
|
135
|
+
reject(new Error('等 ' + method + ' 超时(' + limit + 'ms)'));
|
|
136
|
+
}, limit);
|
|
137
|
+
waiters.push({ method: method, sessionId: sessionId,
|
|
138
|
+
resolve: resolve, reject: reject, timer: timer });
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
send: send,
|
|
144
|
+
waitEvent: waitEvent,
|
|
145
|
+
on: function (method, fn) { listeners.push({ method: method, fn: fn }); },
|
|
146
|
+
close: function () { try { child.kill(); } catch (e) { /* 已退出 */ } },
|
|
147
|
+
stderr: function () { return stderr; },
|
|
148
|
+
child: child
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = { connect: connect };
|
package/ai/cli.js
CHANGED
|
@@ -907,7 +907,7 @@ function cmdPdf(args) {
|
|
|
907
907
|
if (!src) {
|
|
908
908
|
bad('用法:agentcss pdf <页面.html|URL> --out <file.pdf> '
|
|
909
909
|
+ '[--paper=a4|letter|a5|a3|legal] [--margin="18mm 15mm"] '
|
|
910
|
-
+ '[--max-pages=N] [--chrome=<path>] [--json]');
|
|
910
|
+
+ '[--max-pages=N] [--wait-ms=800] [--ready-selector=<CSS>] [--chrome=<path>] [--json]');
|
|
911
911
|
}
|
|
912
912
|
var remote = /^https?:\/\//i.test(src);
|
|
913
913
|
var outFile = flag('out', null);
|
|
@@ -923,6 +923,13 @@ function cmdPdf(args) {
|
|
|
923
923
|
if (budget !== null && !/^\d+$/.test(String(budget))) {
|
|
924
924
|
bad('--max-pages 必须是 ≥1 的整数,收到 ' + JSON.stringify(String(budget)));
|
|
925
925
|
}
|
|
926
|
+
var waitRaw = flag('wait-ms', null);
|
|
927
|
+
if (waitRaw === true) bad('--wait-ms 后面要跟 0–60000 的整数毫秒');
|
|
928
|
+
if (typeof waitRaw === 'string' && !/^\d+$/.test(waitRaw)) {
|
|
929
|
+
bad('--wait-ms 必须是 0–60000 的整数,收到 ' + JSON.stringify(waitRaw));
|
|
930
|
+
}
|
|
931
|
+
var ready = flag('ready-selector', null);
|
|
932
|
+
if (ready === true || ready === '') bad('--ready-selector 必须是非空 CSS 选择器');
|
|
926
933
|
var chrome = flag('chrome', null);
|
|
927
934
|
if (chrome === true) bad('--chrome 后面要跟可执行文件路径');
|
|
928
935
|
|
|
@@ -932,6 +939,8 @@ function cmdPdf(args) {
|
|
|
932
939
|
paper: flag('paper', pdfExport.DEFAULT_PAPER),
|
|
933
940
|
margin: flag('margin', pdfExport.DEFAULT_MARGIN),
|
|
934
941
|
budget: budget === null ? null : Number(budget),
|
|
942
|
+
waitMs: waitRaw === null ? undefined : Number(waitRaw),
|
|
943
|
+
readySelector: ready,
|
|
935
944
|
chrome: chrome
|
|
936
945
|
});
|
|
937
946
|
|
|
@@ -951,6 +960,9 @@ function cmdPdf(args) {
|
|
|
951
960
|
bytes: r.bytes || null,
|
|
952
961
|
injected: r.injected === undefined ? null : r.injected,
|
|
953
962
|
chrome: r.chrome || null,
|
|
963
|
+
waitMs: r.waitMs === undefined ? null : r.waitMs,
|
|
964
|
+
readySelector: r.readySelector || null,
|
|
965
|
+
jsErrors: r.jsErrors || [],
|
|
954
966
|
ms: r.ms || null
|
|
955
967
|
};
|
|
956
968
|
if (!r.ok) {
|
|
@@ -973,6 +985,11 @@ function cmdPdf(args) {
|
|
|
973
985
|
process.stdout.write(' ' + r.pages + ' 页 · ' + report.paperSize + ' · '
|
|
974
986
|
+ (r.margin ? '边距 ' + r.margin : '边距由页面决定')
|
|
975
987
|
+ ' · ' + r.bytes + ' 字节 · ' + r.ms + ' ms\n');
|
|
988
|
+
process.stdout.write(' 就绪:加载与字体完成 + 额外等待 ' + r.waitMs + ' ms'
|
|
989
|
+
+ (r.readySelector ? ' + 可见元素 ' + r.readySelector : '') + '\n');
|
|
990
|
+
if (r.jsErrors && r.jsErrors.length) {
|
|
991
|
+
process.stdout.write(' ⚠️ 页面有 ' + r.jsErrors.length + ' 条未捕获 JS 异常,请核对内容\n');
|
|
992
|
+
}
|
|
976
993
|
process.stdout.write(' 预算 ' + (r.budget === null ? '未设(无界)' : r.budget + ' 页')
|
|
977
994
|
+ ' · ' + (r.injected
|
|
978
995
|
? '纸幅 ' + r.paper
|
|
@@ -1178,7 +1195,7 @@ var FLAGS = {
|
|
|
1178
1195
|
profile: [],
|
|
1179
1196
|
capabilities: [],
|
|
1180
1197
|
pitfalls: ['component', 'scope', 'tag'],
|
|
1181
|
-
pdf: ['out', 'paper', 'margin', 'max-pages', 'chrome'],
|
|
1198
|
+
pdf: ['out', 'paper', 'margin', 'max-pages', 'wait-ms', 'ready-selector', 'chrome'],
|
|
1182
1199
|
scaffold: ['out', 'css-prefix'],
|
|
1183
1200
|
shot: ['out', 'width', 'height', 'full', 'scale', 'wait', 'chrome']
|
|
1184
1201
|
};
|
|
@@ -1231,8 +1248,9 @@ function usage() {
|
|
|
1231
1248
|
' ai profile [--json] 列出三档及其差异\n' +
|
|
1232
1249
|
' ai capabilities [--json] 列出可协商的能力\n' +
|
|
1233
1250
|
' ai pdf <页面.html|URL> --out <file.pdf> [--paper=a4|letter|a5|a3|legal]\n' +
|
|
1234
|
-
' [--margin="18mm 15mm"] [--max-pages=N] [--
|
|
1235
|
-
'
|
|
1251
|
+
' [--margin="18mm 15mm"] [--max-pages=N] [--wait-ms=800]\n' +
|
|
1252
|
+
' [--ready-selector=<CSS>] [--chrome=<path>] [--json]\n' +
|
|
1253
|
+
' 等加载、字体与就绪元素后导出;失败不改写旧 PDF。零依赖,@page 由导出方给。\n' +
|
|
1236
1254
|
' ai scaffold <版式> --out <page.html> [--css-prefix=<前缀>] [--json]\n' +
|
|
1237
1255
|
' 生成最小壳(正确的骨架 + 必有件位置),不含内容。`list` 看有哪些版式。\n' +
|
|
1238
1256
|
' ai shot <页面.html|URL> --out <shot-{width}.png> [--width=390,1440] [--height=900]\n' +
|
package/ai/components.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": "1.0.0",
|
|
3
3
|
"generatedBy": "05-audit/gen-ai-contract.py",
|
|
4
|
-
"libraryVersion": "0.27.
|
|
4
|
+
"libraryVersion": "0.27.1",
|
|
5
5
|
"$comment": "机器可读的组件契约。slots = BEM 元素名(在本库里元素名就是插槽名),states.enum = 组件会在 DOM 上写出的 data-* 取值,states.legacyClass = 仍未收敛到 data 属性的历史状态类(INVARIANT I-8 未达标)。printPolicy = 这个组件在纸上会发生什么(空数组 = 原样保留);pagination = 内容是否被分页裁过(server 不许与 expand 同时出现,否则展开的只是当前一页)。",
|
|
6
6
|
"maturityCriteria": {
|
|
7
7
|
"alpha": "缺 demo.html 或缺 05-audit 下的专属门禁 ⇒ 还没被机器验证过,不要用",
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* pdf-renderer.js — exportPdf 的异步子进程;不是公共 CLI。
|
|
4
|
+
* 从 stdin 接收一份 JSON 配置,从 stdout 返回一份 JSON 结论。
|
|
5
|
+
* 浏览器只写工作目录里的暂存 PDF,绝不接触调用方的最终输出路径。
|
|
6
|
+
*/
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
var fs = require('fs');
|
|
10
|
+
var cdp = require('./cdp.js');
|
|
11
|
+
|
|
12
|
+
var DEFAULT_WAIT = 800;
|
|
13
|
+
var READY_INTERVAL = 50;
|
|
14
|
+
|
|
15
|
+
function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
|
|
16
|
+
function timeoutError(message) {
|
|
17
|
+
var e = new Error(message);
|
|
18
|
+
e.code = 'timeout';
|
|
19
|
+
return e;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* 在 print 媒体下等待一个可见元素。元素只是存在但 display:none 不算就绪。 */
|
|
23
|
+
function readyExpression(selector) {
|
|
24
|
+
return '(function(){try{var e=document.querySelector(' + JSON.stringify(selector) + ');'
|
|
25
|
+
+ 'if(!e)return {ok:true,ready:false};var s=getComputedStyle(e);'
|
|
26
|
+
+ 'return {ok:true,ready:s.display!=="none"&&s.visibility!=="hidden"&&e.getClientRects().length>0};'
|
|
27
|
+
+ '}catch(e){return {ok:false,error:String(e)}}})()';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function render(o) {
|
|
31
|
+
var started = Date.now();
|
|
32
|
+
var budget = o.timeoutMs || 60000;
|
|
33
|
+
var deadline = started + budget;
|
|
34
|
+
function left() { return Math.max(1, deadline - Date.now()); }
|
|
35
|
+
function checkTime(what) {
|
|
36
|
+
if (Date.now() >= deadline) throw timeoutError(what + '超出时间预算 ' + budget + 'ms');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
var browser = cdp.connect(o.chrome, o.profile);
|
|
40
|
+
var jsErrors = [];
|
|
41
|
+
var timedOut = false;
|
|
42
|
+
var timer = setTimeout(function () {
|
|
43
|
+
timedOut = true;
|
|
44
|
+
browser.close();
|
|
45
|
+
}, budget);
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
var target = await browser.send('Target.createTarget', { url: 'about:blank' }, null, left());
|
|
49
|
+
var attached = await browser.send('Target.attachToTarget', {
|
|
50
|
+
targetId: target.targetId, flatten: true
|
|
51
|
+
}, null, left());
|
|
52
|
+
var sid = attached.sessionId;
|
|
53
|
+
await browser.send('Page.enable', {}, sid, left());
|
|
54
|
+
await browser.send('Runtime.enable', {}, sid, left());
|
|
55
|
+
browser.on('Runtime.exceptionThrown', function (p) {
|
|
56
|
+
if (jsErrors.length >= 20) return;
|
|
57
|
+
var d = (p && p.exceptionDetails) || {};
|
|
58
|
+
jsErrors.push(String((d.exception && d.exception.description) || d.text || '未捕获异常').split('\n')[0]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
var loaded = browser.waitEvent('Page.loadEventFired', sid, left());
|
|
62
|
+
var nav = await browser.send('Page.navigate', { url: o.target }, sid, left());
|
|
63
|
+
if (nav && nav.errorText) {
|
|
64
|
+
var loadError = new Error('页面加载失败(' + nav.errorText + '):' + o.src);
|
|
65
|
+
loadError.code = 'render';
|
|
66
|
+
throw loadError;
|
|
67
|
+
}
|
|
68
|
+
await loaded;
|
|
69
|
+
checkTime('页面加载');
|
|
70
|
+
|
|
71
|
+
/* 先固定打印环境,再判断 ready:print-only 元素也能作为就绪标志。 */
|
|
72
|
+
await browser.send('Emulation.setEmulatedMedia', {
|
|
73
|
+
media: 'print',
|
|
74
|
+
features: [{ name: 'prefers-reduced-motion', value: 'reduce' }]
|
|
75
|
+
}, sid, left());
|
|
76
|
+
var fonts = await browser.send('Runtime.evaluate', {
|
|
77
|
+
expression: 'document.fonts&&document.fonts.ready?document.fonts.ready.then(function(){return true}):true',
|
|
78
|
+
awaitPromise: true, returnByValue: true
|
|
79
|
+
}, sid, left());
|
|
80
|
+
if (fonts.exceptionDetails) {
|
|
81
|
+
throw new Error('等待字体失败:' + (fonts.exceptionDetails.text || 'document.fonts.ready rejected'));
|
|
82
|
+
}
|
|
83
|
+
checkTime('字体加载');
|
|
84
|
+
|
|
85
|
+
if (o.readySelector !== null && o.readySelector !== undefined) {
|
|
86
|
+
for (;;) {
|
|
87
|
+
checkTime('等待 ready-selector ' + JSON.stringify(o.readySelector));
|
|
88
|
+
var ready = await browser.send('Runtime.evaluate', {
|
|
89
|
+
expression: readyExpression(o.readySelector), returnByValue: true
|
|
90
|
+
}, sid, left());
|
|
91
|
+
if (!ready.result || !ready.result.value || !ready.result.value.ok) {
|
|
92
|
+
var invalid = new Error('--ready-selector 不是合法 CSS 选择器:' + o.readySelector);
|
|
93
|
+
invalid.code = 'usage';
|
|
94
|
+
throw invalid;
|
|
95
|
+
}
|
|
96
|
+
if (ready.result.value.ready) break;
|
|
97
|
+
await sleep(Math.min(READY_INTERVAL, left()));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
var waitMs = o.waitMs === undefined ? DEFAULT_WAIT : o.waitMs;
|
|
102
|
+
if (waitMs) {
|
|
103
|
+
var room = left();
|
|
104
|
+
/* 剩余预算装不下这次等待时,先耗尽它再报 timeout —— 这样报错里的
|
|
105
|
+
"等的是什么 / 还剩多少"和实际发生的事一致,而不是只睡一半就下结论。 */
|
|
106
|
+
if (waitMs > room) {
|
|
107
|
+
await sleep(room);
|
|
108
|
+
throw timeoutError('额外等待 ' + waitMs + 'ms 超出剩余预算 ' + room + 'ms');
|
|
109
|
+
}
|
|
110
|
+
await sleep(waitMs);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* 有限动画落到终态,无限动画取消;同时关掉打印过渡,避免首帧 opacity:0。 */
|
|
114
|
+
var animations = await browser.send('Runtime.evaluate', {
|
|
115
|
+
expression: '(function(){var n=0;document.getAnimations().forEach(function(a){try{'
|
|
116
|
+
+ 'var t=a.effect&&a.effect.getComputedTiming?a.effect.getComputedTiming():null;'
|
|
117
|
+
+ 'if(t&&isFinite(t.iterations)){a.finish()}else{a.cancel()}n++}catch(e){try{a.cancel()}catch(_){}}});'
|
|
118
|
+
+ 'var s=document.createElement("style");s.setAttribute("data-agentcss-print-motion","");'
|
|
119
|
+
+ 's.textContent="@media print{*,*::before,*::after{transition:none!important}}";'
|
|
120
|
+
+ 'document.head.appendChild(s);return n})()',
|
|
121
|
+
returnByValue: true
|
|
122
|
+
}, sid, left());
|
|
123
|
+
if (animations.exceptionDetails) throw new Error('固定打印动画失败');
|
|
124
|
+
|
|
125
|
+
var printed = await browser.send('Page.printToPDF', {
|
|
126
|
+
printBackground: true,
|
|
127
|
+
preferCSSPageSize: true,
|
|
128
|
+
displayHeaderFooter: false,
|
|
129
|
+
marginTop: 0, marginBottom: 0, marginLeft: 0, marginRight: 0
|
|
130
|
+
}, sid, left());
|
|
131
|
+
checkTime('PDF 生成');
|
|
132
|
+
var buf = Buffer.from(printed.data || '', 'base64');
|
|
133
|
+
if (buf.length < 5 || buf.subarray(0, 5).toString('latin1') !== '%PDF-') {
|
|
134
|
+
throw new Error('浏览器返回的不是 PDF(' + buf.length + ' 字节)');
|
|
135
|
+
}
|
|
136
|
+
fs.writeFileSync(o.stagedPdf, buf);
|
|
137
|
+
return {
|
|
138
|
+
ok: true, stagedPdf: o.stagedPdf, bytes: buf.length, jsErrors: jsErrors,
|
|
139
|
+
waitMs: waitMs, readySelector: o.readySelector || null,
|
|
140
|
+
animations: animations.result ? animations.result.value : null,
|
|
141
|
+
ms: Date.now() - started
|
|
142
|
+
};
|
|
143
|
+
} catch (e) {
|
|
144
|
+
return {
|
|
145
|
+
ok: false, code: timedOut ? 'timeout' : (e.code === 'usage' || e.code === 'timeout' ? e.code : 'render'),
|
|
146
|
+
message: timedOut ? 'PDF 导出超出时间预算 ' + budget + 'ms(页面加载、字体或 ready-selector 未完成)'
|
|
147
|
+
: String(e && e.message || e),
|
|
148
|
+
jsErrors: jsErrors, ms: Date.now() - started,
|
|
149
|
+
stderr: browser.stderr().slice(-800)
|
|
150
|
+
};
|
|
151
|
+
} finally {
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
browser.close();
|
|
154
|
+
/* Windows 上 Chrome 刚退出时仍可能持有 profile 文件句柄。 */
|
|
155
|
+
await sleep(150);
|
|
156
|
+
try { fs.rmSync(o.profile, { recursive: true, force: true, maxRetries: 3 }); }
|
|
157
|
+
catch (e) { /* 父进程还会清理整个暂存目录 */ }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
var input = '';
|
|
162
|
+
process.stdin.setEncoding('utf8');
|
|
163
|
+
process.stdin.on('data', function (s) { input += s; });
|
|
164
|
+
process.stdin.on('end', function () {
|
|
165
|
+
var config;
|
|
166
|
+
try { config = JSON.parse(input); }
|
|
167
|
+
catch (e) {
|
|
168
|
+
process.stdout.write(JSON.stringify({ ok: false, code: 'render', message: '导出子进程收到非法配置' }));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
render(config).then(function (result) {
|
|
172
|
+
process.stdout.write(JSON.stringify(result));
|
|
173
|
+
}).catch(function (e) {
|
|
174
|
+
process.stdout.write(JSON.stringify({ ok: false, code: 'render',
|
|
175
|
+
message: String(e && e.message || e) }));
|
|
176
|
+
});
|
|
177
|
+
});
|
package/ai/pdf.js
CHANGED
|
@@ -16,14 +16,14 @@
|
|
|
16
16
|
* 所以它只能由"产出这份文件的人"给 —— 这正是本文件存在的理由。
|
|
17
17
|
*
|
|
18
18
|
* ============================================================================
|
|
19
|
-
* 🔴
|
|
19
|
+
* 🔴 为什么走共享 CDP 管道,而不是 Puppeteer 或 Chrome 一次性命令行
|
|
20
20
|
* ============================================================================
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* 本库保持**零运行时依赖**,使用机器上已装好的 Chrome/Chromium/Edge。
|
|
22
|
+
* `cdp.js` 复用截图已有的调试管道:能等加载、字体和指定元素,再 printToPDF。
|
|
23
|
+
* 一次性 `--print-to-pdf` 无法表达就绪条件,也无法可靠区分新旧产物。
|
|
24
24
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* 浏览器只写同目录暂存文件;确认本次成功后才原子替换最终 PDF。任何失败都
|
|
26
|
+
* 明确返回错误并保留旧文件,绝不把上一次产物当成本次成功。
|
|
27
27
|
*
|
|
28
28
|
* ============================================================================
|
|
29
29
|
* ⚠️ 一条必须写下来的观测差异
|
|
@@ -202,6 +202,26 @@ function paperSpec(name) {
|
|
|
202
202
|
return PAPERS[k] ? { key: k, ...PAPERS[k] } : null;
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
/* 硬杀(Ctrl+C / 断电 / 被任务管理器结束)会留下 `.agentcss-print-*.html`:
|
|
206
|
+
finally 没机会跑。下次导出前顺手清掉**同目录**里超过 6 小时的残留。
|
|
207
|
+
门槛设长是为了不碰另一个正在导出的进程留下的文件(单次导出是秒级)。 */
|
|
208
|
+
const STALE_TMP_MS = 6 * 3600 * 1000;
|
|
209
|
+
|
|
210
|
+
function sweepStaleTemplates(dir) {
|
|
211
|
+
let names;
|
|
212
|
+
try { names = fs.readdirSync(dir); } catch (e) { return 0; }
|
|
213
|
+
var removed = 0;
|
|
214
|
+
const cutoff = Date.now() - STALE_TMP_MS;
|
|
215
|
+
for (const n of names) {
|
|
216
|
+
if (!/^\.agentcss-print-.*\.html$/.test(n)) continue;
|
|
217
|
+
const p = path.join(dir, n);
|
|
218
|
+
try {
|
|
219
|
+
if (fs.statSync(p).mtimeMs < cutoff) { fs.unlinkSync(p); removed += 1; }
|
|
220
|
+
} catch (e) { /* 别处正在用 / 已删 */ }
|
|
221
|
+
}
|
|
222
|
+
return removed;
|
|
223
|
+
}
|
|
224
|
+
|
|
205
225
|
/** 注入到 `<head>` 末尾(**必须在使用者样式之后**,否则同特异性被覆盖)。 */
|
|
206
226
|
function injectSheet(html, css) {
|
|
207
227
|
const style = '<style id="agentcss-paper" data-agentcss="paper">' + css + '</style>';
|
|
@@ -230,7 +250,9 @@ function isRemote(src) {
|
|
|
230
250
|
* @param {string} [o.margin] CSS margin 简写(默认 18mm 15mm)
|
|
231
251
|
* @param {number} [o.budget] 页数上限;超了返回 over-budget
|
|
232
252
|
* @param {string} [o.chrome] Chrome 可执行文件
|
|
233
|
-
* @param {number} [o.
|
|
253
|
+
* @param {number} [o.waitMs] 就绪后的额外等待,0–60000(默认 800)
|
|
254
|
+
* @param {string} [o.readySelector] 等待一个打印媒体下可见的 CSS 元素
|
|
255
|
+
* @param {number} [o.timeoutMs] 总预算,1000–600000(默认 60000)
|
|
234
256
|
* @returns {object} `{ok:true, ...}` 或 `{ok:false, code, message, ...}`
|
|
235
257
|
*/
|
|
236
258
|
function exportPdf(o) {
|
|
@@ -254,107 +276,129 @@ function exportPdf(o) {
|
|
|
254
276
|
if (o.budget !== undefined && o.budget !== null) {
|
|
255
277
|
const b = Number(o.budget);
|
|
256
278
|
if (!Number.isInteger(b) || b < 1) {
|
|
257
|
-
/* ⚠️ 用 String() 而不是 JSON.stringify:`JSON.stringify(NaN)` 是 `"null"`,
|
|
258
|
-
于是 `--max-pages=abc` 会报「收到 null」—— 使用者据这句话会去查一个
|
|
259
|
-
根本不存在的 null(实测踩过)。 */
|
|
260
279
|
return { ok: false, code: 'usage',
|
|
261
280
|
message: '--max-pages 必须是 ≥1 的整数,收到 ' + String(o.budget) };
|
|
262
281
|
}
|
|
263
282
|
}
|
|
283
|
+
const waitMs = o.waitMs === undefined ? 800 : Number(o.waitMs);
|
|
284
|
+
if (!Number.isInteger(waitMs) || waitMs < 0 || waitMs > 60000) {
|
|
285
|
+
return { ok: false, code: 'usage',
|
|
286
|
+
message: '--wait-ms 必须是 0–60000 的整数,收到 ' + String(o.waitMs) };
|
|
287
|
+
}
|
|
288
|
+
if (o.readySelector !== undefined && o.readySelector !== null
|
|
289
|
+
&& (typeof o.readySelector !== 'string' || !o.readySelector.trim())) {
|
|
290
|
+
return { ok: false, code: 'usage', message: '--ready-selector 必须是非空 CSS 选择器' };
|
|
291
|
+
}
|
|
292
|
+
const timeoutMs = o.timeoutMs === undefined ? 60000 : Number(o.timeoutMs);
|
|
293
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 600000) {
|
|
294
|
+
return { ok: false, code: 'usage', message: 'timeoutMs 必须是 1000–600000 的整数' };
|
|
295
|
+
}
|
|
264
296
|
|
|
265
297
|
const chrome = resolveChrome(o.chrome);
|
|
266
298
|
if (chrome.error) return { ok: false, code: 'no-chrome', message: chrome.error };
|
|
267
299
|
|
|
268
300
|
const outAbs = path.resolve(o.out);
|
|
269
301
|
fs.mkdirSync(path.dirname(outAbs), { recursive: true });
|
|
302
|
+
const staging = fs.mkdtempSync(path.join(path.dirname(outAbs), '.agentcss-pdf-'));
|
|
303
|
+
const profile = path.join(staging, 'profile');
|
|
304
|
+
fs.mkdirSync(profile);
|
|
305
|
+
const stagedPdf = path.join(staging, 'output.pdf');
|
|
270
306
|
|
|
271
307
|
/* 本地文件才注入 @page:远程页面我们拿不到它的字节,硬注入等于假装改了它。 */
|
|
272
308
|
let target = o.src;
|
|
273
309
|
let tmpHtml = null;
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
310
|
+
let rendered = null;
|
|
311
|
+
let buf = null;
|
|
312
|
+
let info = null;
|
|
313
|
+
try {
|
|
314
|
+
if (!isRemote(o.src)) {
|
|
315
|
+
const srcAbs = path.resolve(o.src);
|
|
316
|
+
if (!fs.existsSync(srcAbs)) {
|
|
317
|
+
return { ok: false, code: 'usage', message: '找不到输入文件:' + srcAbs };
|
|
318
|
+
}
|
|
319
|
+
const html = fs.readFileSync(srcAbs, 'utf8');
|
|
320
|
+
sweepStaleTemplates(path.dirname(srcAbs));
|
|
321
|
+
/* 与输入同目录,页面里的相对样式、字体和图片才不会失效。 */
|
|
322
|
+
tmpHtml = path.join(path.dirname(srcAbs),
|
|
323
|
+
'.agentcss-print-' + process.pid + '-' + Date.now().toString(36) + '.html');
|
|
324
|
+
fs.writeFileSync(tmpHtml, injectSheet(html, cssFor(spec, margin)), 'utf8');
|
|
325
|
+
target = pathToFileURL(tmpHtml).href;
|
|
278
326
|
}
|
|
279
|
-
const html = fs.readFileSync(srcAbs, 'utf8');
|
|
280
|
-
/* ⚠️ 临时文件必须落在**同一个目录**:换成系统临时目录,页面里的
|
|
281
|
-
`./tokens.css` / `/02-primitives/...` 会全部 404,而产物看起来"只是没样式"。 */
|
|
282
|
-
tmpHtml = path.join(path.dirname(srcAbs),
|
|
283
|
-
'.agentcss-print-' + process.pid + '-' + Date.now().toString(36) + '.html');
|
|
284
|
-
fs.writeFileSync(tmpHtml, injectSheet(html, cssFor(spec, margin)), 'utf8');
|
|
285
|
-
target = pathToFileURL(tmpHtml).href;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'agentcss-chrome-'));
|
|
289
|
-
const args = [
|
|
290
|
-
'--headless=new',
|
|
291
|
-
'--disable-gpu',
|
|
292
|
-
'--disable-extensions',
|
|
293
|
-
'--no-first-run',
|
|
294
|
-
'--no-default-browser-check',
|
|
295
|
-
/* 每次一个新 profile:使用者可能正开着 Chrome,共用 profile 会抢锁 */
|
|
296
|
-
'--user-data-dir=' + profile,
|
|
297
|
-
/* 页眉页脚(日期 / 标题 / 1/2)默认是开的,必须显式关掉 */
|
|
298
|
-
'--no-pdf-header-footer',
|
|
299
|
-
'--print-to-pdf-no-header',
|
|
300
|
-
'--print-to-pdf=' + outAbs,
|
|
301
|
-
target,
|
|
302
|
-
];
|
|
303
|
-
/* 容器里以 root 跑才需要关沙箱 —— 默认不开,也不替使用者决定 */
|
|
304
|
-
if (process.env.AGENTCSS_NO_SANDBOX === '1') args.unshift('--no-sandbox');
|
|
305
327
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
328
|
+
/* 公共 API 保持同步;异步 CDP 只在独立 Node 子进程中运行。 */
|
|
329
|
+
const res = spawnSync(process.execPath, [path.join(__dirname, 'pdf-renderer.js')], {
|
|
330
|
+
input: JSON.stringify({
|
|
331
|
+
src: o.src, target: target, chrome: chrome.path, profile: profile,
|
|
332
|
+
stagedPdf: stagedPdf, waitMs: waitMs,
|
|
333
|
+
readySelector: o.readySelector === undefined ? null : o.readySelector,
|
|
334
|
+
timeoutMs: timeoutMs,
|
|
335
|
+
}),
|
|
336
|
+
timeout: timeoutMs + 10000,
|
|
337
|
+
encoding: 'utf8', windowsHide: true, maxBuffer: 1024 * 1024,
|
|
338
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
313
339
|
});
|
|
340
|
+
if (res.error && res.error.code === 'ETIMEDOUT') {
|
|
341
|
+
return { ok: false, code: 'timeout', out: outAbs, ms: Date.now() - t0,
|
|
342
|
+
message: 'PDF 导出子进程没有按时退出;最终文件未被改写' };
|
|
343
|
+
}
|
|
344
|
+
if (res.error || res.status !== 0) {
|
|
345
|
+
return { ok: false, code: 'render', out: outAbs, ms: Date.now() - t0,
|
|
346
|
+
message: 'PDF 导出子进程失败:' + String(res.error || ('退出码 ' + res.status))
|
|
347
|
+
+ (res.stderr ? '\n ' + String(res.stderr).slice(-800) : '') };
|
|
348
|
+
}
|
|
349
|
+
try { rendered = JSON.parse(res.stdout); } catch (e) { /* 下方按工具失败处理 */ }
|
|
350
|
+
if (!rendered) {
|
|
351
|
+
return { ok: false, code: 'render', out: outAbs, ms: Date.now() - t0,
|
|
352
|
+
message: 'PDF 导出子进程没有返回合法结果;最终文件未被改写' };
|
|
353
|
+
}
|
|
354
|
+
if (!rendered.ok) {
|
|
355
|
+
/* 子进程的 stagedPdf 在 finally 里已被删掉 —— 不要把它交出去,
|
|
356
|
+
否则调用方会拿到一个"看着像产物、其实不存在"的路径。 */
|
|
357
|
+
const { stagedPdf: _staged, ...rest } = rendered;
|
|
358
|
+
return { ...rest, out: outAbs, chrome: chrome.path, ms: Date.now() - t0 };
|
|
359
|
+
}
|
|
360
|
+
if (!fs.existsSync(stagedPdf)) {
|
|
361
|
+
return { ok: false, code: 'render', out: outAbs, chrome: chrome.path, ms: Date.now() - t0,
|
|
362
|
+
message: '浏览器报告成功但没有生成本次暂存 PDF;最终文件未被改写' };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/* 文件头与页树都先在暂存文件上验证,通过后才接触最终路径。 */
|
|
366
|
+
buf = fs.readFileSync(stagedPdf);
|
|
367
|
+
if (buf.length < 5 || buf.slice(0, 5).toString('latin1') !== '%PDF-') {
|
|
368
|
+
return { ok: false, code: 'render', out: outAbs, chrome: chrome.path, ms: Date.now() - t0,
|
|
369
|
+
message: '本次暂存产物不是 PDF(' + buf.length + ' 字节);最终文件未被改写' };
|
|
370
|
+
}
|
|
371
|
+
info = pages.inspect(buf);
|
|
372
|
+
if (info.pages < 1) {
|
|
373
|
+
return { ok: false, code: 'unreadable', out: outAbs, chrome: chrome.path, ms: Date.now() - t0,
|
|
374
|
+
message: '暂存 PDF 没有可读页对象;最终文件未被改写' };
|
|
375
|
+
}
|
|
376
|
+
try { fs.renameSync(stagedPdf, outAbs); } catch (e) {
|
|
377
|
+
return { ok: false, code: 'render', out: outAbs, chrome: chrome.path, ms: Date.now() - t0,
|
|
378
|
+
message: '新 PDF 已生成,但无法替换输出文件:' + e.message + '。旧文件未被主动改写' };
|
|
379
|
+
}
|
|
314
380
|
} finally {
|
|
315
|
-
/* 清理必须**同步**做完:CLI 随后就 process.exit,挂一个 setTimeout
|
|
316
|
-
等于每次导出都在系统临时目录里留一个 Chrome profile(实测漏了 6 个才发现)。
|
|
317
|
-
删不掉不算错(Windows 上偶发 EBUSY),但不能因为删不掉就不删。 */
|
|
318
381
|
if (tmpHtml) { try { fs.unlinkSync(tmpHtml); } catch (e) { /* 已删 */ } }
|
|
319
|
-
try { fs.rmSync(
|
|
320
|
-
catch (e) { /*
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
const ms = Date.now() - t0;
|
|
324
|
-
const stderr = String((res && res.stderr) || '').slice(-800);
|
|
325
|
-
if (res && res.error && res.error.code === 'ETIMEDOUT') {
|
|
326
|
-
return { ok: false, code: 'timeout', message: 'Chrome 在 '
|
|
327
|
-
+ Math.round((o.timeoutMs || 60000) / 1000) + 's 内没有结束(页面可能有死循环或等待网络)', ms };
|
|
328
|
-
}
|
|
329
|
-
if (!fs.existsSync(outAbs)) {
|
|
330
|
-
return { ok: false, code: 'render', ms,
|
|
331
|
-
message: 'Chrome 没有产出文件(退出码 ' + (res ? res.status : '?') + ')'
|
|
332
|
-
+ (stderr ? '\n ' + stderr.trim().split('\n').slice(-4).join('\n ') : '') };
|
|
382
|
+
try { fs.rmSync(staging, { recursive: true, force: true, maxRetries: 3 }); }
|
|
383
|
+
catch (e) { /* Windows 偶发 EBUSY,交给系统清理 */ }
|
|
333
384
|
}
|
|
334
|
-
|
|
335
|
-
const buf = fs.readFileSync(outAbs);
|
|
336
|
-
if (buf.length < 5 || buf.slice(0, 5).toString('latin1') !== '%PDF-') {
|
|
337
|
-
return { ok: false, code: 'render',
|
|
338
|
-
message: '产出的文件不是 PDF(' + buf.length + ' 字节)—— 通常是页面加载失败', ms };
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
const info = pages.inspect(buf);
|
|
342
385
|
const injected = !isRemote(o.src);
|
|
343
386
|
const base = {
|
|
344
|
-
out: outAbs, bytes: buf.length, pdf: buf, ms,
|
|
345
|
-
/* 远程页面没注入 @page ⇒ `paper` 只能是 null:报 "a4" 是在陈述一个
|
|
346
|
-
没有发生的事实(实测远程导出是 612×792)。真实纸幅看 `mediaBox`。 */
|
|
387
|
+
out: outAbs, bytes: buf.length, pdf: buf, ms: Date.now() - t0,
|
|
347
388
|
paper: injected ? spec.key : null,
|
|
348
389
|
margin: injected ? margin : null, chrome: chrome.path,
|
|
349
390
|
pages: info.pages, mediaBox: info.mediaBox,
|
|
350
|
-
injected: injected,
|
|
391
|
+
injected: injected, waitMs: waitMs,
|
|
392
|
+
readySelector: o.readySelector || null,
|
|
393
|
+
jsErrors: rendered.jsErrors || [],
|
|
351
394
|
};
|
|
352
395
|
|
|
353
|
-
/*
|
|
354
|
-
|
|
396
|
+
/* 两套读法不一致时,连"这份 PDF 有几页"都不可信 —— 无论有没有预算。
|
|
397
|
+
只在设了预算时才检查,会让"没设预算"的调用方拿到一个可能是错页数的 ok:true。 */
|
|
398
|
+
if (!info.agree) {
|
|
355
399
|
return { ...base, ok: false, code: 'unreadable',
|
|
356
400
|
message: 'PDF 里 `/Type /Page` 数出 ' + info.pages + ' 页、页树 `/Count` 说 '
|
|
357
|
-
+ info.countFromTree + ' 页 ——
|
|
401
|
+
+ info.countFromTree + ' 页 —— 两套说法不一致,页数不可信' };
|
|
358
402
|
}
|
|
359
403
|
|
|
360
404
|
if (o.budget !== undefined && o.budget !== null && info.pages > o.budget) {
|
|
@@ -393,4 +437,5 @@ module.exports = {
|
|
|
393
437
|
/* 只为可测性导出:浏览器发现逻辑必须在"没有系统 Chrome"的机器上也能被验证,
|
|
394
438
|
否则它只会在 CI 上坏、而开发机永远绿。 */
|
|
395
439
|
chromeCandidates,
|
|
440
|
+
sweepStaleTemplates,
|
|
396
441
|
};
|
package/ai/shot.js
CHANGED
|
@@ -34,9 +34,9 @@
|
|
|
34
34
|
var fs = require('fs');
|
|
35
35
|
var os = require('os');
|
|
36
36
|
var path = require('path');
|
|
37
|
-
var { spawn } = require('child_process');
|
|
38
37
|
var { pathToFileURL } = require('url');
|
|
39
38
|
var pdf = require('./pdf.js');
|
|
39
|
+
var connect = require('./cdp.js').connect;
|
|
40
40
|
|
|
41
41
|
var DEFAULT_WIDTHS = [1440];
|
|
42
42
|
var DEFAULT_HEIGHT = 900;
|
|
@@ -45,114 +45,6 @@ var DEFAULT_WAIT = 800;
|
|
|
45
45
|
截断**不静默**:结果里带 truncatedAt,人/Agent 能看见。 */
|
|
46
46
|
var MAX_FULL_HEIGHT = 10000;
|
|
47
47
|
|
|
48
|
-
/** 极简 CDP 客户端:一个子进程、两个 fd、以 \0 分隔的 JSON。 */
|
|
49
|
-
function connect(chromePath, profile) {
|
|
50
|
-
var child = spawn(chromePath, [
|
|
51
|
-
'--headless=new',
|
|
52
|
-
'--disable-gpu',
|
|
53
|
-
'--disable-extensions',
|
|
54
|
-
'--no-first-run',
|
|
55
|
-
'--no-default-browser-check',
|
|
56
|
-
/* 滚动条会占掉 15px 视口,且它本身不是页面内容 */
|
|
57
|
-
'--hide-scrollbars',
|
|
58
|
-
'--remote-debugging-pipe',
|
|
59
|
-
'--user-data-dir=' + profile,
|
|
60
|
-
'about:blank'
|
|
61
|
-
], {
|
|
62
|
-
/* fd 0/1/2 给普通输出,3 = 命令(Chrome 读),4 = 事件(Chrome 写) */
|
|
63
|
-
stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'],
|
|
64
|
-
windowsHide: true
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
var buf = Buffer.alloc(0);
|
|
68
|
-
var pending = Object.create(null);
|
|
69
|
-
var waiters = [];
|
|
70
|
-
var listeners = [];
|
|
71
|
-
var nextId = 0;
|
|
72
|
-
var dead = null;
|
|
73
|
-
|
|
74
|
-
function dispatch(msg) {
|
|
75
|
-
if (msg.id !== undefined && pending[msg.id]) {
|
|
76
|
-
var p = pending[msg.id];
|
|
77
|
-
delete pending[msg.id];
|
|
78
|
-
clearTimeout(p.timer);
|
|
79
|
-
if (msg.error) p.reject(new Error(p.method + ':' + (msg.error.message || JSON.stringify(msg.error))));
|
|
80
|
-
else p.resolve(msg.result);
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
if (!msg.method) return;
|
|
84
|
-
for (var L = 0; L < listeners.length; L++) {
|
|
85
|
-
if (listeners[L].method === msg.method) listeners[L].fn(msg.params, msg.sessionId);
|
|
86
|
-
}
|
|
87
|
-
for (var i = waiters.length - 1; i >= 0; i--) {
|
|
88
|
-
var w = waiters[i];
|
|
89
|
-
if (w.method === msg.method && (!w.sessionId || w.sessionId === msg.sessionId)) {
|
|
90
|
-
waiters.splice(i, 1);
|
|
91
|
-
clearTimeout(w.timer);
|
|
92
|
-
w.resolve(msg.params);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
child.stdio[4].on('data', function (chunk) {
|
|
98
|
-
buf = Buffer.concat([buf, chunk]);
|
|
99
|
-
var i;
|
|
100
|
-
while ((i = buf.indexOf(0)) >= 0) {
|
|
101
|
-
var raw = buf.subarray(0, i).toString('utf8');
|
|
102
|
-
buf = buf.subarray(i + 1);
|
|
103
|
-
var msg = null;
|
|
104
|
-
try { msg = JSON.parse(raw); } catch (e) { continue; }
|
|
105
|
-
dispatch(msg);
|
|
106
|
-
}
|
|
107
|
-
});
|
|
108
|
-
child.stdio[1].on('data', function () { /* 吞掉:Chrome 的正常输出没有信息量 */ });
|
|
109
|
-
child.stdio[2].on('data', function () { /* 同上;错误通过 CDP 的 error 字段回来 */ });
|
|
110
|
-
child.on('error', function (e) { dead = e; });
|
|
111
|
-
child.on('exit', function (code) {
|
|
112
|
-
dead = dead || new Error('Chrome 提前退出(退出码 ' + code + ')');
|
|
113
|
-
Object.keys(pending).forEach(function (k) {
|
|
114
|
-
clearTimeout(pending[k].timer);
|
|
115
|
-
pending[k].reject(dead);
|
|
116
|
-
});
|
|
117
|
-
pending = Object.create(null);
|
|
118
|
-
waiters.splice(0).forEach(function (w) { clearTimeout(w.timer); w.reject(dead); });
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
function send(method, params, sessionId, timeoutMs) {
|
|
122
|
-
return new Promise(function (resolve, reject) {
|
|
123
|
-
if (dead) return reject(dead);
|
|
124
|
-
var id = ++nextId;
|
|
125
|
-
var timer = setTimeout(function () {
|
|
126
|
-
delete pending[id];
|
|
127
|
-
reject(new Error(method + ' 超时(' + (timeoutMs || 15000) + 'ms 没有回应)'));
|
|
128
|
-
}, timeoutMs || 15000);
|
|
129
|
-
pending[id] = { resolve: resolve, reject: reject, timer: timer, method: method };
|
|
130
|
-
child.stdio[3].write(JSON.stringify({
|
|
131
|
-
id: id, method: method, params: params || {}, sessionId: sessionId
|
|
132
|
-
}) + '\0');
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function waitEvent(method, sessionId, timeoutMs) {
|
|
137
|
-
return new Promise(function (resolve, reject) {
|
|
138
|
-
var timer = setTimeout(function () {
|
|
139
|
-
var at = waiters.findIndex(function (w) { return w.method === method && w.sessionId === sessionId; });
|
|
140
|
-
if (at >= 0) waiters.splice(at, 1);
|
|
141
|
-
reject(new Error('等 ' + method + ' 超时(' + (timeoutMs || 15000) + 'ms)'));
|
|
142
|
-
}, timeoutMs || 15000);
|
|
143
|
-
waiters.push({ method: method, sessionId: sessionId, resolve: resolve, reject: reject, timer: timer });
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
return {
|
|
148
|
-
send: send, waitEvent: waitEvent,
|
|
149
|
-
/* 常驻监听:异常/控制台消息可能在任意时刻到达,用一次性 waiter 会漏掉第二条起的所有 */
|
|
150
|
-
on: function (method, fn) { listeners.push({ method: method, fn: fn }); },
|
|
151
|
-
close: function () { try { child.kill(); } catch (e) { /* 已退出 */ } },
|
|
152
|
-
child: child
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
|
|
156
48
|
/** 页面在某个宽度下的量:溢出多少、谁溢出、有多高。 */
|
|
157
49
|
var PROBE_JS = [
|
|
158
50
|
'(function(){',
|
package/dist/01-tokens/page.css
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
.page {margin:0;min-height:100vh;min-height:100dvh;background:var(--paper);color:var(--text-primary);font-family:var(--sans);font-size:var(--fs-base);line-height:var(--lh-base);-webkit-text-size-adjust:100%;padding-block:calc(env(safe-area-inset-top, 0px) + var(--sp-6)) calc(env(safe-area-inset-bottom, 0px) + var(--sp-6));padding-inline:max(env(safe-area-inset-right, 0px), var(--sp-4)) max(env(safe-area-inset-left, 0px), var(--sp-4));}.page *, .page *::before, .page *::after {box-sizing:border-box;}.page .wrap {margin-inline:auto;max-width:min(var(--measure-page), 100%);}.page .wrap--prose {max-width:min(var(--measure-prose), 100%);}.page h1 {font-family:var(--sans);font-size:var(--fs-2xl);font-weight:500;letter-spacing:var(--tracking-title);line-height:var(--lh-tight);margin:0 0 var(--sp-3);max-width:var(--measure-title);}.page h2 {font-size:var(--fs-lg);margin:var(--sp-7) 0 var(--sp-3);}.page .rule-top {border-top:1px solid var(--border-decor);padding-top:var(--sp-4);}.page .rule-bottom {border-bottom:1px solid var(--border-decor);padding-bottom:var(--sp-2);margin-bottom:var(--sp-4);}.page code {font-family:var(--mono);font-size:0.9em;background:var(--surface-sunken);padding:1px 4px;border-radius:var(--r-xs);}:where(.page) pre {overflow-x:auto;}.page .box {background:var(--surface);border:1px solid var(--border-decor);border-radius:var(--r-md);padding:var(--sp-5);}.page .box.bad {background:var(--surface-sunken);}.page .cmp {display:grid;grid-template-columns:minmax(0, 1fr) minmax(0, 1fr);gap:var(--sp-4);}.page .cmp--wide-first {grid-template-columns:minmax(0, 1.55fr) minmax(0, 1fr);}@media (max-width:640px) {.page .cmp, .page .cmp--wide-first, .page .cmp--clip {grid-template-columns:minmax(0, 1fr);}}.page .cmp__pane {padding:var(--sp-4);border:1px solid var(--border-decor);border-radius:var(--r-md);background:var(--surface-sunken);min-width:0;}.page .cmp--clip .cmp__pane {overflow:hidden;}.page .cmp__pane > h3 {margin:0 0 var(--sp-3);font-size:var(--fs-sm);font-weight:500;}.page .cmp__bad > h3 {color:var(--danger);}.page .cmp__good > h3 {color:var(--success);}.page .cmp__why {margin:var(--sp-3) 0 0;font-size:var(--fs-xs);line-height:1.6;color:var(--text-secondary);}
|
|
1
|
+
.page {margin:0;min-height:100vh;min-height:100dvh;background:var(--paper);color:var(--text-primary);font-family:var(--sans);font-size:var(--fs-base);line-height:var(--lh-base);-webkit-text-size-adjust:100%;padding-block:calc(env(safe-area-inset-top, 0px) + var(--sp-6)) calc(env(safe-area-inset-bottom, 0px) + var(--sp-6));padding-inline:max(env(safe-area-inset-right, 0px), var(--sp-4)) max(env(safe-area-inset-left, 0px), var(--sp-4));}@media print {.page {min-height:0;padding-block-end:0;}}.page *, .page *::before, .page *::after {box-sizing:border-box;}.page .wrap {margin-inline:auto;max-width:min(var(--measure-page), 100%);}.page .wrap--prose {max-width:min(var(--measure-prose), 100%);}.page h1 {font-family:var(--sans);font-size:var(--fs-2xl);font-weight:500;letter-spacing:var(--tracking-title);line-height:var(--lh-tight);margin:0 0 var(--sp-3);max-width:var(--measure-title);}.page h2 {font-size:var(--fs-lg);margin:var(--sp-7) 0 var(--sp-3);}.page .rule-top {border-top:1px solid var(--border-decor);padding-top:var(--sp-4);}.page .rule-bottom {border-bottom:1px solid var(--border-decor);padding-bottom:var(--sp-2);margin-bottom:var(--sp-4);}.page code {font-family:var(--mono);font-size:0.9em;background:var(--surface-sunken);padding:1px 4px;border-radius:var(--r-xs);}:where(.page) pre {overflow-x:auto;}.page .box {background:var(--surface);border:1px solid var(--border-decor);border-radius:var(--r-md);padding:var(--sp-5);}.page .box.bad {background:var(--surface-sunken);}.page .cmp {display:grid;grid-template-columns:minmax(0, 1fr) minmax(0, 1fr);gap:var(--sp-4);}.page .cmp--wide-first {grid-template-columns:minmax(0, 1.55fr) minmax(0, 1fr);}@media (max-width:640px) {.page .cmp, .page .cmp--wide-first, .page .cmp--clip {grid-template-columns:minmax(0, 1fr);}}.page .cmp__pane {padding:var(--sp-4);border:1px solid var(--border-decor);border-radius:var(--r-md);background:var(--surface-sunken);min-width:0;}.page .cmp--clip .cmp__pane {overflow:hidden;}.page .cmp__pane > h3 {margin:0 0 var(--sp-3);font-size:var(--fs-sm);font-weight:500;}.page .cmp__bad > h3 {color:var(--danger);}.page .cmp__good > h3 {color:var(--success);}.page .cmp__why {margin:var(--sp-3) 0 0;font-size:var(--fs-xs);line-height:1.6;color:var(--text-secondary);}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
.tabs {display:flex;flex-direction:column;gap:var(--sp-4);}.tabs--manual .tabs__tab[aria-selected="true"] {color:var(--text-secondary);font-weight:400;}@media (hover:hover) and (pointer:fine) {.tabs--manual .tabs__tab[aria-selected="true"]:hover {color:var(--text-primary);}}.tabs__list {display:flex;gap:var(--sp-1);border-bottom:1px solid var(--border-decor);position:relative;overflow-x:auto;overflow-y:hidden;scrollbar-width:none;}.tabs__list::-webkit-scrollbar {display:none;}.tabs--vertical {flex-direction:row;gap:var(--sp-4);}.tabs--vertical .tabs__list {flex-direction:column;border-bottom:0;border-inline-end:1px solid var(--border-decor);}.tabs__tab {appearance:none;background:none;border:0;margin-bottom:-1px;padding:var(--sp-3) var(--sp-4);font:inherit;font-size:var(--fs-base);color:var(--text-secondary);cursor:pointer;border-bottom:2px solid transparent;transition:color var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out);white-space:nowrap;flex:0 0 auto;}.tabs--vertical .tabs__tab {margin-bottom:0;margin-inline-end:-1px;border-bottom:0;border-inline-end:2px solid transparent;text-align:start;}.tabs__tab[aria-selected="true"] {color:var(--text-primary);font-weight:600;border-bottom-color:var(--accent);}.tabs--vertical .tabs__tab[aria-selected="true"] {border-inline-end-color:var(--accent);}@media (hover:hover) and (pointer:fine) {.tabs__tab:not([aria-disabled="true"]):hover {color:var(--text-primary);background:var(--surface-sunken);}}.tabs__tab[aria-disabled="true"] {color:var(--text-tertiary);cursor:not-allowed;}.tabs__tab:focus-visible {outline:2px solid var(--accent);outline-offset:-2px;border-radius:var(--r-sm) var(--r-sm) 0 0;}.tabs__panel {padding:var(--sp-4) 0;}.tabs__panel[hidden] {display:none;}@media (prefers-reduced-motion:no-preference) {.tabs__panel--enter {animation:tabs-fade var(--dur-base) var(--ease-out);}}@keyframes tabs-fade {from {opacity:0;}to {opacity:1;}}
|
|
1
|
+
.tabs {display:flex;flex-direction:column;gap:var(--sp-4);}.tabs--manual .tabs__tab[aria-selected="true"] {color:var(--text-secondary);font-weight:400;}@media (hover:hover) and (pointer:fine) {.tabs--manual .tabs__tab[aria-selected="true"]:hover {color:var(--text-primary);}}.tabs__list {display:flex;gap:var(--sp-1);border-bottom:1px solid var(--border-decor);position:relative;overflow-x:auto;overflow-y:hidden;scrollbar-width:none;}.tabs__list::-webkit-scrollbar {display:none;}.tabs--vertical {flex-direction:row;gap:var(--sp-4);}.tabs--vertical .tabs__list {flex-direction:column;border-bottom:0;border-inline-end:1px solid var(--border-decor);}.tabs__tab {appearance:none;background:none;border:0;margin-bottom:-1px;padding:var(--sp-3) var(--sp-4);font:inherit;font-size:var(--fs-base);color:var(--text-secondary);cursor:pointer;border-bottom:2px solid transparent;transition:color var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out);white-space:nowrap;flex:0 0 auto;}.tabs--vertical .tabs__tab {margin-bottom:0;margin-inline-end:-1px;border-bottom:0;border-inline-end:2px solid transparent;text-align:start;}.tabs__tab[aria-selected="true"] {color:var(--text-primary);font-weight:600;border-bottom-color:var(--accent);}.tabs--vertical .tabs__tab[aria-selected="true"] {border-inline-end-color:var(--accent);}@media (hover:hover) and (pointer:fine) {.tabs__tab:not([aria-disabled="true"]):hover {color:var(--text-primary);background:var(--surface-sunken);}}.tabs__tab[aria-disabled="true"] {color:var(--text-tertiary);cursor:not-allowed;}.tabs__tab:focus-visible {outline:2px solid var(--accent);outline-offset:-2px;border-radius:var(--r-sm) var(--r-sm) 0 0;}.tabs__panel {padding:var(--sp-4) 0;}.tabs__panel[hidden] {display:none;}@media (prefers-reduced-motion:no-preference) {.tabs__panel--enter {animation:tabs-fade var(--dur-base) var(--ease-out);}}@keyframes tabs-fade {from {opacity:0;}to {opacity:1;}}@media print {.tabs__panel--enter {animation:none;transition:none;opacity:1;}}
|
package/dist/COSTS.md
CHANGED
|
@@ -46,14 +46,14 @@
|
|
|
46
46
|
| pattern | `pagination` | 2.9 KB | 0.8 KB | 2.8 KB | **6.5 KB** | 32.7 KB | −26.3 KB |
|
|
47
47
|
| pattern | `reveal` | 2.9 KB | 0.3 KB | 1.2 KB | **4.4 KB** | 26.4 KB | −22.0 KB |
|
|
48
48
|
| pattern | `states` | 2.9 KB | 0.9 KB | — | **3.8 KB** | 26.3 KB | −22.5 KB |
|
|
49
|
-
| pattern | `tabs` | 2.9 KB | 0.7 KB | 2.0 KB | **5.
|
|
49
|
+
| pattern | `tabs` | 2.9 KB | 0.7 KB | 2.0 KB | **5.7 KB** | 29.2 KB | −23.5 KB |
|
|
50
50
|
| pattern | `tooltip` | 2.9 KB | 0.5 KB | 2.0 KB | **5.4 KB** | 28.7 KB | −23.3 KB |
|
|
51
51
|
| pattern | `tree` | 2.9 KB | 0.6 KB | 3.1 KB | **6.6 KB** | 30.3 KB | −23.7 KB |
|
|
52
52
|
|
|
53
53
|
## 三、全库
|
|
54
54
|
|
|
55
55
|
- 全部组件都用上(min,含 tokens 一份):**72.6 KB**
|
|
56
|
-
- 源码版(带注释)全库 gzip:386.
|
|
56
|
+
- 源码版(带注释)全库 gzip:386.7 KB
|
|
57
57
|
|
|
58
58
|
> ⚠️ 源码版那个数字**不是**要付的钱:它包含我们的注释(全库 CSS 有 69% 是注释,字节口径)。
|
|
59
59
|
> 它的用途只有一个 —— 让人看清"dist 到底省了多少"。
|
package/docs/ACCESSIBILITY.md
CHANGED
package/docs/PRINT.md
CHANGED
|
@@ -258,9 +258,9 @@ node 05-audit/print-gate.js --pdf 某文件.pdf
|
|
|
258
258
|
| `print-content-audit.js` | 该印的在不在?(屏幕可见 → 纸上消失?) | 它只在浏览器里量,不读 PDF |
|
|
259
259
|
| `agentcss pdf` + `pdf-export-gate.js` | **导出成文件**这条路能用吗?(§八) | 它不做视觉比对 |
|
|
260
260
|
|
|
261
|
-
前四个判的是"浏览器里按 Ctrl+P",最后一个判的是"导出一份文件"
|
|
262
|
-
|
|
263
|
-
|
|
261
|
+
前四个判的是"浏览器里按 Ctrl+P",最后一个判的是"导出一份文件"。两者都走 CDP,
|
|
262
|
+
但 `@page` 来源、就绪条件、底色参数与输出保护不同;打印策略全绿推不出导出链没有
|
|
263
|
+
旧文件假成功或异步漏内容,所以两边各有各的判据。
|
|
264
264
|
|
|
265
265
|
`printPolicy` 是**有限枚举**,每个值对应一组能在浏览器里量的断言:
|
|
266
266
|
|
|
@@ -352,20 +352,36 @@ node 05-audit/print-policy-gate.js --selftest # 反向控制
|
|
|
352
352
|
npx agentcss pdf report.html --out report.pdf # 默认 A4 / 18mm 15mm
|
|
353
353
|
npx agentcss pdf report.html --out report.pdf --paper=letter --margin="20mm"
|
|
354
354
|
npx agentcss pdf report.html --out report.pdf --max-pages=8
|
|
355
|
+
npx agentcss pdf report.html --out report.pdf --ready-selector="#report-ready" --wait-ms=0
|
|
355
356
|
```
|
|
356
357
|
|
|
357
358
|
`--paper` 支持 `a4` / `letter` / `a5` / `a3` / `legal`。边距是 CSS 简写
|
|
358
359
|
(`18mm`、`"18mm 15mm"`、四段都认)。这两个参数靠**注入一条 `@page` 规则**实现,
|
|
359
360
|
注入位置在使用者样式之后 —— 否则同特异性会被盖掉(同一个病)。
|
|
360
361
|
|
|
361
|
-
零运行时依赖:用使用者机器上**已装好的** Chrome / Chromium / Edge
|
|
362
|
-
|
|
362
|
+
零运行时依赖:用使用者机器上**已装好的** Chrome / Chromium / Edge,经共享 CDP
|
|
363
|
+
管道调用 `Page.printToPDF`,不打包 Puppeteer。找不到浏览器明确报错(退出码 3):
|
|
363
364
|
|
|
364
365
|
```bash
|
|
365
366
|
npx agentcss pdf report.html --out report.pdf --chrome="C:/Program Files/Google/Chrome/Application/chrome.exe"
|
|
366
367
|
# 或环境变量 AGENTCSS_CHROME / CHROME_PATH
|
|
367
368
|
```
|
|
368
369
|
|
|
370
|
+
#### 就绪、失败与旧文件保护
|
|
371
|
+
|
|
372
|
+
导出顺序是:页面 `load` → `document.fonts.ready` → 可选可见元素 → 额外等待 →
|
|
373
|
+
固定动画终态 → PDF。`--wait-ms` 默认 `800`,可设 `0`,上限 `60000`;它只是额外缓冲,
|
|
374
|
+
**不是任意异步数据已经完成的证明**。动态报告应在数据与图表都渲染完后显示一个元素,
|
|
375
|
+
再传 `--ready-selector="#report-ready"`;只存在但 `display:none`、不可见或没有尺寸不算就绪。
|
|
376
|
+
|
|
377
|
+
整个导出默认有 60 秒总预算,超时退出码为 `4`。非法 CSS 选择器、空值与非法等待
|
|
378
|
+
都是用法错误(退出码 `2`)。未捕获的页面 JS 异常会放进 JSON 的 `jsErrors` 供复核,
|
|
379
|
+
普通 `console.error` 不会擅自让导出失败。
|
|
380
|
+
|
|
381
|
+
浏览器只写暂存 PDF;确认本次成功后才原子替换输出文件。浏览器崩溃、就绪超时、
|
|
382
|
+
非法选择器或产物校验失败时,**已有 PDF 保持不变,结论明确为失败**。超页数预算不同:
|
|
383
|
+
完整的新 PDF 会保留,退出码 `5`,由作者决定如何拆改,绝不静默截断。
|
|
384
|
+
|
|
369
385
|
### 8.2 页数预算:超了报错,不替你截断
|
|
370
386
|
|
|
371
387
|
```bash
|
|
@@ -422,8 +438,8 @@ npx agentcss ai check doc.json --medium=paper
|
|
|
422
438
|
### 8.5 这条路谁守着
|
|
423
439
|
|
|
424
440
|
```bash
|
|
425
|
-
node 05-audit/pdf-export-gate.js # E1–
|
|
426
|
-
node 05-audit/pdf-export-gate.js --selftest #
|
|
441
|
+
node 05-audit/pdf-export-gate.js # E1–E20,不需要静态服务,约 40s
|
|
442
|
+
node 05-audit/pdf-export-gate.js --selftest # 旧文件保护 / 超时 / 崩溃结论 / 非法参数
|
|
427
443
|
```
|
|
428
444
|
|
|
429
445
|
| 判据 | 它问什么 | 实测抓到过什么 |
|
|
@@ -436,15 +452,27 @@ node 05-audit/pdf-export-gate.js --selftest # 只跑"必须红"的几条,约 0
|
|
|
436
452
|
| E7 | `--margin` 真的影响页数吗 | 不注入 `@page` 时 60mm 与 10mm 页数相同 |
|
|
437
453
|
| E8/E9″ | 没有浏览器时说人话吗;报错回显原始输入吗 | 退出码不对 / `abc` 被报成 `null` |
|
|
438
454
|
| E11 | 产物本身合格吗(字体链 / 每页有文本) | 交给 `print-gate --pdf` 的分析器判,不重复实现解析 |
|
|
455
|
+
| E13 | 页树 `/Count` 与 `/Type /Page` 数一致吗 | 分段页树按"第一个"取 ⇒ 50 页读成 8 页 |
|
|
456
|
+
| E14 | ready-selector 等到的是内容,不只是耗时吗 | 延迟新增行的文字 runs 必须增加(48 → 143) |
|
|
457
|
+
| E15 | 真实金融长文每页都有内容吗 | 透明面板与全空白尾页;现为 7 页、每页有文字 |
|
|
458
|
+
| E16 | 浏览器失败会冒用旧 PDF 吗 | 旧文件 SHA-256 必须不变,结论必须失败 |
|
|
459
|
+
| E17 | 就绪条件永不出现会无限等吗 | 有界 timeout,且不碰旧文件 |
|
|
460
|
+
| E18 | 新参数写错会静默按默认值导出吗 | 非法 wait / 空值 / 非法 CSS 都是退出码 2 |
|
|
461
|
+
| E19 | 浏览器起不来时给的是结论还是堆栈 | 去掉管道 error 监听 ⇒ 进程以 EPIPE 崩溃 |
|
|
462
|
+
| E20 | 强杀留下的临时 HTML 会一直堆着吗 | 下次导出清掉 6 小时前的残留,刚生成的不碰 |
|
|
439
463
|
|
|
440
464
|
🔴 E3 与 E7 是同一件事的两面:`@page` 注入是这套东西唯一的技术前提。
|
|
441
465
|
把它改坏,门禁必须红 —— 实测过:注释掉注入那行,E3 报
|
|
442
466
|
`a4=612×792pt`(回落成 Letter),E7 报 `10mm=2 页 / 60mm=2 页`。
|
|
443
467
|
|
|
468
|
+
E14–E20 则保证“导出链说成功”与“这份产物真是本次、完整、可用”不会脱节。
|
|
469
|
+
|
|
444
470
|
### 8.6 不覆盖什么
|
|
445
471
|
|
|
446
472
|
- **远程 URL 不注入 `@page`**:拿不到远程页面的字节,硬注入等于假装改了它。
|
|
447
473
|
这类导出会明确打印"(远程页面:未注入 @page)",纸幅由页面自己的 CSS 决定。
|
|
448
|
-
-
|
|
449
|
-
|
|
450
|
-
|
|
474
|
+
- **跨机器像素基线**:库内不把字体光栅化差异当成回归;正式交付仍需对实际 PDF
|
|
475
|
+
逐页栅格化,检查空白、裁切、边框与图表,并做一次实体打印打样。
|
|
476
|
+
- **页眉页脚 / 页码**:导出器显式 `displayHeaderFooter:false`,本工具不生成自己的。
|
|
477
|
+
需要页码时用浏览器打印对话框的页眉页脚选项,或在自有 CDP/Puppeteer 层提供
|
|
478
|
+
`headerTemplate` / `footerTemplate`;不要假设 `@page + counter(page)` 在 Chrome 可用。
|
package/index.html
CHANGED
package/llms-full.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# agentcss — full reference (one file)
|
|
2
2
|
|
|
3
|
-
> Generated from `ai/components.json` + `ai/pitfalls*.json` + `ai/behaviors.json` at version **0.27.
|
|
3
|
+
> Generated from `ai/components.json` + `ai/pitfalls*.json` + `ai/behaviors.json` at version **0.27.1**.
|
|
4
4
|
> Do not edit by hand: run `python 05-audit/gen-llms.py` (freshness is gated).
|
|
5
5
|
> Short version: `llms.txt`. Machine contract: `ai/*.json`.
|
|
6
6
|
|
|
@@ -20,7 +20,8 @@ npx agentcss ai check <doc.json> --profile=strict --json # the plan
|
|
|
20
20
|
npx agentcss ai check <page.html> --json # the artifact
|
|
21
21
|
npx agentcss ai scaffold report/finance --out report.html # skeleton only
|
|
22
22
|
npx agentcss ai shot <page.html> --out shot-{width}.png --width=390,1440 --full
|
|
23
|
-
npx agentcss ai pdf <page.html> --out page.pdf
|
|
23
|
+
npx agentcss ai pdf <page.html> --out page.pdf --wait-ms=800 # zero deps: your Chrome
|
|
24
|
+
[--ready-selector="#report-ready"] # wait for async content
|
|
24
25
|
```
|
|
25
26
|
|
|
26
27
|
Every subcommand accepts `--json`. On failure it prints *one* JSON document —
|
|
@@ -56,7 +57,7 @@ and it reports horizontal overflow plus JS errors.
|
|
|
56
57
|
| `pagination` | `pagination` | pattern | stable | 7.7 KB | `pagination.js` | 分页控件 | — |
|
|
57
58
|
| `reveal` | `reveal` | pattern | stable | 4.0 KB | `reveal.js` | 入场动效:一个 data-reveal 属性六个方向,无 JS 时内容全可见 | — |
|
|
58
59
|
| `states` | `state` | pattern | stable | 10.9 KB | — | 空/加载/错误/成功四种整块状态 | `empty-state-no-decoration` `text-injection-xss` |
|
|
59
|
-
| `tabs` | `tabs` | pattern | stable |
|
|
60
|
+
| `tabs` | `tabs` | pattern | stable | 6.1 KB | `tabs.js` | 选项卡切换 | — |
|
|
60
61
|
| `tooltip` | `tooltip` | pattern | stable | 3.5 KB | `tooltip.js` | 悬停/聚焦提示 | `popover-measure-after-render` `fixed-element-inventory` |
|
|
61
62
|
| `tree` | `tree` | pattern | stable | 4.0 KB | `tree.js` | 可展开树形结构 | — |
|
|
62
63
|
| `badge` | `badge` | primitive | stable | 6.1 KB | — | 状态标签:计数、分类、结果标记 | — |
|
package/llms.txt
CHANGED
|
@@ -25,7 +25,9 @@ npx agentcss ai check page.html --json # check the page you actual
|
|
|
25
25
|
npx agentcss ai scaffold report/finance --out report.html # minimal shell (skeleton, no content)
|
|
26
26
|
npx agentcss ai shot page.html --out shot-{width}.png --width=390,1440 --full
|
|
27
27
|
# PNG at exact viewport widths
|
|
28
|
-
npx agentcss ai pdf page.html --out page.pdf --max-pages=8
|
|
28
|
+
npx agentcss ai pdf page.html --out page.pdf --max-pages=8 --ready-selector="#report-ready"
|
|
29
|
+
# waits for load + fonts + visible async content;
|
|
30
|
+
# zero deps; failed renders preserve an old PDF
|
|
29
31
|
```
|
|
30
32
|
|
|
31
33
|
`check <doc.json>` and `check <page.html>` answer **different** questions: the JSON one
|