# Figma To PDF

> 将设计作品集图片压缩拼接成可以控制大小的高清 PDF，支持两种模式：①本地图片文件夹直接打包；②粘贴 Figma 链接，Claude 自动从 Figma 导出 2x 图再打包。保留渐变和颜色，无锯齿无波浪纹。

- Skill: `realwooolf/figma-to-pdf` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add realwooolf/figma-to-pdf`
- Raw SKILL.md: https://api.skillmd.com/api/skills/realwooolf/figma-to-pdf/raw
- Safety review: pending (external: skill-scanner PASS, skillspector CAUTION)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Design & Media
- Author: realwooolf (https://skillmd.com/u/realwooolf)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/realwooolf/figma-to-pdf

---


# PDF 高清压缩 Skill

## 完整交互流程

---

### 第一步：介绍 + 判断账号类型

先用文字介绍，再带出问题——**不要直接弹问题**，要先说：

> **figma-to-pdf** 帮你把 Figma 设计稿导出成可以控制大小的高清 PDF。
>
> 你的 Figma 账号是哪种类型？

然后用 AskUserQuestion 工具弹出选项：

```
问题：你的 Figma 账号类型？
选项：
- Professional / Organization / Enterprise
    → 描述：支持从 Figma 链接直接导出，也可以用本地文件夹
- Starter（免费版）
    → 描述：需要先在 Figma 手动导出图片，再给我文件夹路径打包
```

### 第二步：判断图片来源（仅 Professional 以上账号）

若用户选择 Professional 以上账号，再用 AskUserQuestion 工具问：

```
问题：图片从哪里来？
选项：
- 从 Figma 链接导入（推荐）
    → 描述：粘贴 Figma 文件链接，自动按画布顺序导出所有 Frame
- 从本地文件夹导入
    → 描述：已在 Figma 手动导出图片，给我文件夹路径直接打包
```

- 选"从 Figma 链接导入"→ 进入模式 B
- 选"从本地文件夹导入"→ 进入模式 A（跳过手动导出引导，直接问路径）
- Starter 账号 → 进入模式 A（显示手动导出引导）

---

## 模式 A：本地文件夹

### A-1. 引导导出（仅免费账号显示）

若用户是免费账号，先引导手动导出：

> 在 Figma 里手动导出步骤：
> 1. 选中画布上所有要导出的 Frame（Ctrl/Cmd+A 全选，或逐个点选）
> 2. 右侧面板底部找到 **Export**
> 3. 格式选 PNG，Scale 选 2x
> 4. 点 **Export [n] layers**，选择保存文件夹
> 5. 导出完成后把文件夹路径告诉我

付费账号选了本地文件夹，直接跳到 A-2。

### A-2. 询问路径

> 请提供图片文件夹路径（直接粘贴或把文件夹拖进终端）

### A-3. 采样估算大小

扫描文件夹，均匀取 5 张图（第 1、1/4、1/2、3/4、最后一张）分别用 3 种 quality 压缩，按总页数推算预估体积：

```python
imgs = sorted(glob.glob(f"{src_dir}/*.jpg") + glob.glob(f"{src_dir}/*.png"),
              key=lambda p: int(re.sub(r'\D', '', os.path.basename(p)) or 0))
n = len(imgs)
sample_indices = list({0, n//4, n//2, 3*n//4, n-1})
samples = [imgs[i] for i in sample_indices]

for quality in [92, 85, 75]:
    total = 0
    for path in samples:
        img = Image.open(path).convert("RGB")
        img.save(tmp, "JPEG", quality=quality)
        total += os.path.getsize(tmp)
    avg = total / len(samples)
    estimated_mb = avg * n / 1024 / 1024
```

### A-4. 询问目标大小

用 AskUserQuestion 工具弹出选项（填入采样估算值）：

```
问题：目标文件大小？
选项：
- 高清（quality=92）→ 约 XX MB，几乎无损，适合本地存档
- 均衡（quality=85）→ 约 XX MB，肉眼无差别，适合邮件/微信（推荐）
- 压缩（quality=75）→ 约 XX MB，深色背景有色带风险，适合平台上传
- 自定义：我来指定目标大小
```

若用户选"自定义"，回复：

> 请输入目标大小（如 15MB），我自动调参命中。

收到目标值后，根据采样数据估算对应 quality，生成后若偏差超过 1 MB 则微调再生成，直到落入目标范围。若目标值低于 quality=75 能达到的大小，提示用户有色带风险并询问是否继续。

### A-5. 询问保存路径

在开始压缩前用 AskUserQuestion 弹出选项：

```
问题：PDF 保存到哪里？
选项：
- 桌面（默认）
    → 描述：保存到 ~/Desktop/
- 自定义路径
    → 描述：选择后在下方 Other 框内填入文件夹路径
```

### A-6. 执行

1. 读取所有 JPG/PNG，按文件名数字排序
2. 按选定 quality 压缩，动态计算每张图 DPI 嵌入元数据，用 img2pdf 打包，直接写入目标路径
3. 报告最终大小，用 `open` 命令打开文件，完成确认

---

## 模式 B：Figma 直接导出

**前提：需要 Figma Professional 以上付费账号 + Full/Dev 席位。**

免费 Starter 账号的 `/v1/images` 导出限额为 6次/月，无法支持大文件导出，请使用模式 A。

### B-1. 收集链接与 Token

> 请提供 Figma 链接，支持三种类型：
> - **整个文件**：导出文件内所有 Page 的顶层 Frame
> - **某个 Page**（推荐）：只导出该 Page 下的顶层 Frame
> - **某个 Frame / Section**：只导出该 Frame 或 Section 内的内容
>
> 推荐粘 **Page 级别的链接**——在 Figma 左侧切换到目标 Page，复制地址栏链接即可。
>
> ⚠️ 注意：Page 内**不能有多余的 Frame**，所有顶层 Frame 都会被导出。请确认画布里只保留要打包的页面。
>
> ⚠️ 导出顺序按画布**从左到右**排列，请先在 Figma 里确认 Frame 的横向摆放顺序正确。

**Token 获取逻辑：**

**Step 1：检查环境变量**
```bash
TOKEN="$FIGMA_TOKEN"
```
- 若有，直接使用，跳过以下步骤
- 若无，引导用户生成 token

**Step 2：引导生成 Token（环境变量未配置时显示）**

> 需要先生成一个 Figma Personal Access Token，步骤如下：
> 1. 打开 Figma，点右上角头像 → **Settings**
> 2. 顶部选 **Security**
> 3. 滚动到 **Personal access tokens** → 点 **Generate new token**
> 4. 名字随便填（如 `pdf-export`）
> 5. 权限只勾选 **File content → Read-only**，其余不用勾
> 6. 过期时间按需选（建议 90 天或更长）
> 7. 点 **Generate**，复制 token（**只显示这一次，复制后再关闭**）
>
> 生成好了把 token 粘给我。

**Step 3：询问是否永久保存（用户粘 token 后）**

用 AskUserQuestion 工具弹出选项：
```
问题：要把 token 保存到 ~/.zshrc 吗？以后不用再粘。
选项：
- 保存（推荐）：写入 ~/.zshrc，长期有效
- 不保存：只用这一次
```

若选保存，执行：
```bash
echo 'export FIGMA_TOKEN="figd_xxxx..."' >> ~/.zshrc
```

**Token 失效处理（API 返回 403）：**

若导出时遇到 `{"status": 403, "err": "Invalid token"}`，提示用户：

> Token 已失效或过期，请重新生成一个。
>
> 生成步骤：
> 1. 打开 Figma，点右上角头像 → **Settings**
> 2. 顶部选 **Security**
> 3. 滚动到 **Personal access tokens** → 点 **Generate new token**
> 4. 名字随便填（如 `pdf-export`）
> 5. 权限只勾选 **File content → Read-only**，其余不用勾
> 6. 过期时间按需选（建议 90 天或更长）
> 7. 点 **Generate**，复制 token（**只显示这一次，复制后再关闭**）
>
> 生成好了把新 token 粘给我，我同时帮你更新 ~/.zshrc。

### B-2. 读取 Frame 列表并确认顺序

调用 `/v1/files/:file_key` API，获取所有顶层 Frame 及其 `absoluteBoundingBox`，**按画布 X 坐标升序排列**，展示给用户确认：

> 找到 N 个 Frame，按画布从左到右排列如下：
> 1. Cover
> 2. Problem
> ...
>
> 顺序有问题吗？没问题直接回"确认"，需要调整告诉我。

**若用户要调整**：按用户指定的顺序重新排列，再次展示确认后进入下一步。

### B-3. 分批导出

`/v1/images` 每次调用都触发服务端渲染，帧越大越容易超时。**固定每批 5 个，超时自动降级重试。**

**导出逻辑（用 bash curl 执行）：**

```bash
curl -s "https://api.figma.com/v1/images/{file_key}?ids={ids}&scale=2&format=jpg" \
  -H "X-Figma-Token: {token}" -o batch.json
```

**错误处理逻辑：**

```
读取 batch.json 的 err 字段：

err == "Rate limit exceeded"
  → 停止导出，提示用户：
    "已触发 Figma API 限速。限速规则取决于文件所在的位置，不只是账号类型——
     即使是 Pro 账号，文件如果放在个人草稿（Drafts）里，API 仍按 Starter 限额计算（约每分钟 10 次）。
     
     解决方案：
     1. 把 Figma 文件移到 Pro 团队项目里，限额提升到每分钟 50 次，再重试
     2. 或改用模式 A：在 Figma 里手动导出帧为 PNG 2x，给我文件夹路径直接打包"

err == "Render timeout, ..."（或其他 err）
  → 缩小本批次：5 个 → 重试 3 个 → 重试 1 个
  → 1 个还超时：跳过该帧，继续下一个，最后告知用户哪些帧跳过了

err == null
  → 成功，继续下一批
```

node_ids 格式：冒号编码为 `%3A`，逗号**不编码**，例如 `5%3A544,5%3A629`

### B-4. 下载图片

下载所有图片到 `/tmp/figma_export_<timestamp>/`，按序号命名（01.jpg、02.jpg…）。

### B-5. 采样估算 + 询问目标大小

图片下载完成后，均匀取 5 张采样估算（同模式 A 的采样逻辑），再用 AskUserQuestion 弹出选项：

```
问题：目标文件大小？
选项：
- 高清（quality=92）→ 约 XX MB，几乎无损，适合本地存档
- 均衡（quality=85）→ 约 XX MB，肉眼无差别，适合邮件/微信（推荐）
- 压缩（quality=75）→ 约 XX MB，深色背景有色带风险，适合平台上传
- 自定义：我来指定目标大小
```

若用户选"自定义"，回复：

> 请输入目标大小（如 15MB），我自动调参命中。

### B-6. 询问保存路径

在开始压缩前用 AskUserQuestion 弹出选项：

```
问题：PDF 保存到哪里？
选项：
- 桌面（默认）
    → 描述：保存到 ~/Desktop/
- 自定义路径
    → 描述：选择后在下方 Other 框内填入文件夹路径
```

### B-7. 压缩打包与收尾

1. 同模式 A 的压缩逻辑，**动态计算每张图的 DPI**，直接写入目标路径
2. 报告最终文件大小，用 `open` 命令打开 PDF，完成确认
3. 清理 `/tmp/figma_export_<timestamp>/` 临时目录

---

## 技术实现

### 依赖检查

```bash
pip3 install --break-system-packages Pillow img2pdf -q
```

### 压缩打包代码模板

```python
import img2pdf, os, re, time, glob
from PIL import Image

TARGET_WIDTH_INCHES = 10  # 所有页面统一宽度：10 英寸（254mm），适合屏幕阅读

def compress_and_pack(img_paths, dst, jpeg_quality=85):
    tmp_dir = f"/tmp/portfolio_compress_{int(time.time())}"
    os.makedirs(tmp_dir, exist_ok=True)

    compressed = []
    for i, src in enumerate(img_paths):
        img = Image.open(src)
        if img.mode != "RGB":
            img = img.convert("RGB")
        # 根据每张图的实际宽度动态计算 DPI，使所有页面宽度一致
        effective_dpi = img.width / TARGET_WIDTH_INCHES
        out = os.path.join(tmp_dir, f"{i+1:02d}.jpg")
        img.save(out, "JPEG", quality=jpeg_quality, optimize=True, dpi=(effective_dpi, effective_dpi))
        img.close()
        compressed.append(out)

    # 不传 dpi 参数，img2pdf 自动读取每张图嵌入的 DPI
    with open(dst, "wb") as f:
        f.write(img2pdf.convert(compressed))

    for f in compressed:
        os.remove(f)
    os.rmdir(tmp_dir)

    return os.path.getsize(dst) / 1024 / 1024
```

### 参数调优策略

按目标大小调整 `jpeg_quality`：

| 目标 | quality | 说明 |
|---|---|---|
| ~50 MB | 92 | 几乎无损 |
| ~20 MB | 85 | 均衡，推荐默认 |
| ~15 MB | 80 | 可接受 |
| ~10 MB | 75 | 深色渐变背景会出现明显色带，慎用 |
| < 10 MB | 降 quality + 缩小尺寸 | 明显有损 |

**quality 建议不低于 75**——低于此值深色渐变背景会出现明显色带。

若目标值低于 quality=75 能达到的大小，提示用户：

> 继续压缩需要缩小图片尺寸，深色区域会有轻微色带，是否继续？

### 关键原则

- **必须用 `img2pdf`**：零转码嵌入，不产生渐变波浪纹
- **DPI 动态计算**：根据每张图的实际像素宽度计算 effective_dpi，嵌入 JPEG 元数据，img2pdf 自动读取——所有页面统一 10 英寸宽，高度按各自比例自动适配，不传全局 dpi 参数
- **不用 PyMuPDF / fitz**：会重新编码导致色带
- **不用 Ghostscript**：破坏复杂布局
- 缩放必须用 `Image.LANCZOS`
- Figma token 不写入项目文件（保存到 ~/.zshrc 作为环境变量是允许的）

