16 Commits
Author SHA1 Message Date
rogerwang2008 6d4bfc2253 feat: page2 画框内嵌入手枪图片并修正居中
图片裁掉画布左侧多余透明边后铺在「图」区之上,四边各缩 0.1em 不压到「框」;
本地备份 *.orig.png 加入 .gitignore。
2026-11-11 05:13:44 +08:00
rogerwang2008 f1888fbaf8 feat: PDF 导出脚本支持三页合一
逐页渲染 page1/page2/page3 并用 pypdf 合并为单个 PDF,默认输出 dist/三页demo.pdf;
--page 改为可多值的 --pages(保留旧名),每页各自校验四边 1em 与 Zpix 字体。
README 同步导出用法与各页说明。
2026-11-11 04:18:11 +08:00
rogerwang2008 7a39a8fe2c feat: 移除 page2 桌下的枪
删掉倒数第二、三行的三个「枪」字,桌面之下只留墙背景。
2026-11-11 04:17:55 +08:00
rogerwang2008 07c2ab1f25 feat: page3 铺「黑」背景并统一配色,page1 落日修正为正圆
page3 空白格按 page1「天」的明度铺「黑」,nametag 与「演职员表」四周整格避让
(标题留白缩矮一格后居中,牌子整体上移一格);电线、麻雀、草、消防栓配色并入
熔岩暖色族。page1 去掉太阳上下端帽,行宽按半径 8 的圆逐行取整。
标题非强调部分改用与两页小字一致的 #e6dac6。
2026-11-11 04:10:59 +08:00
rogerwang2008 b0da74bb96 feat: 落日第一排加宽为四个「日」
y=18 改为 x=12、w=4,保持太阳中轴 x=14 不变。
2026-11-11 03:54:01 +08:00
rogerwang2008 38c7a83cb5 feat: 第 2 页满铺「墙」背景并新增墙上画框
背景按 page1「天」的明度铺满「墙」,前景标题与两列曲目四周整格扣空并居中;
灯笼右移一格、链加长两节使整体下沉;墙上以「框」包「图」拼出画框。
2026-11-11 03:53:52 +08:00
rogerwang2008 f253c8acf2 feat: 第 2 页新增灯笼与酒水单前景列表
右上角以链/笼/灯/穗拼出灯笼;前景标题「酒水单」下按 references/节目单.txt
排两列曲目(前 5 后 7,数字与曲名各自左对齐);酒杯改为深绿玻璃色。
2026-11-11 03:25:00 +08:00
rogerwang2008 b84340fac1 feat: 新增第 2 页静物文字画
地、桌、椅与桌下暗枪构成场景,桌上竖排酒杯与烛火,右下角酒桶,右上角吊灯。
2026-11-11 02:11:54 +08:00
rogerwang2008 54235eb631 feat: 草按列加高,第 1、8、15、27 列多一格 2026-11-11 01:03:08 +08:00
rogerwang2008 e9e4357bf1 Merge branch 'feature/nametag' 2026-11-11 00:55:39 +08:00
rogerwang2008 b9f8eddaaa feat: 补满 4×9 nametag 网格 2026-11-11 00:52:00 +08:00
rogerwang2008 a39931ee38 docs: 名单添加虞韫嘉 2026-11-11 00:51:15 +08:00
rogerwang2008 c7fa3f0982 docs: 名单添加虞韫嘉 2026-11-11 00:51:05 +08:00
rogerwang2008 2342812005 style: 提高火山明度至 50% 2026-11-11 00:49:12 +08:00
rogerwang2008 6406858015 feat: 添加演职员 nametag 并完善 page3
- 35 个 nametag 按 4×9 网格排布,右下角缺一,每个占 5×2 格
- 姓名取自演职员表.txt,整体下移 2 格
- 同步移除枪、更新配色与 README
2026-11-11 00:28:05 +08:00
rogerwang2008 26018d01b1 docs: 添加演职员表名单 2026-11-11 00:26:10 +08:00
15 changed files with 503 additions and 60 deletions

No files matched your search

+3
View File
@@ -12,6 +12,9 @@ desktop.ini
*.log *.log
.tmp-* .tmp-*
# 图片本地备份(不入库)
*.orig.png
# Python / uv # Python / uv
.venv/ .venv/
__pycache__/ __pycache__/
+19 -7
View File
@@ -2,7 +2,7 @@
一个 28 列 × 44 行的汉字文字画 Demo。黑底、每格一个汉字,空白处由淡色「天」铺满;画面包含晚霞云、落日、飞鸟、天线与城市楼群,上方以整格扣出的黑色标题框居中呈现「关于「甲子」的虚构故事」。 一个 28 列 × 44 行的汉字文字画 Demo。黑底、每格一个汉字,空白处由淡色「天」铺满;画面包含晚霞云、落日、飞鸟、天线与城市楼群,上方以整格扣出的黑色标题框居中呈现「关于「甲子」的虚构故事」。
项目规划为三张类似页面,当前实现第 1 页(`page1.html`)与第 3 页(`page3.html`)。 项目规划为三张类似页面,当前实现第 1 页(`page1.html`)、第 2 页(`page2.html`)与第 3 页(`page3.html`)。
## 效果概述 ## 效果概述
@@ -17,6 +17,7 @@
``` ```
. .
├─ page1.html # 第 1 页入口 ├─ page1.html # 第 1 页入口
├─ page2.html # 第 2 页入口
├─ page3.html # 第 3 页入口 ├─ page3.html # 第 3 页入口
├─ pyproject.toml # uv 环境与导出依赖 ├─ pyproject.toml # uv 环境与导出依赖
├─ uv.lock # 依赖锁定 ├─ uv.lock # 依赖锁定
@@ -25,10 +26,12 @@
│ ├─ css/ │ ├─ css/
│ │ ├─ common.css # 三页共用:字体、骨架、网格、标题框 │ │ ├─ common.css # 三页共用:字体、骨架、网格、标题框
│ │ ├─ page1.css # 第 1 页配色 │ │ ├─ page1.css # 第 1 页配色
│ │ ├─ page2.css # 第 2 页配色
│ │ └─ page3.css # 第 3 页配色与前景标题 │ │ └─ page3.css # 第 3 页配色与前景标题
│ ├─ js/ │ ├─ js/
│ │ ├─ text-scene.js # 共享文字画渲染引擎 │ │ ├─ text-scene.js # 共享文字画渲染引擎
│ │ ├─ page1.js # 第 1 页场景数据 │ │ ├─ page1.js # 第 1 页场景数据
│ │ ├─ page2.js # 第 2 页场景数据
│ │ └─ page3.js # 第 3 页场景数据 │ │ └─ page3.js # 第 3 页场景数据
│ └─ fonts/zpix.ttf # Zpix 像素字体 │ └─ fonts/zpix.ttf # Zpix 像素字体
└─ references/a.html # 原始参考页 └─ references/a.html # 原始参考页
@@ -41,6 +44,7 @@
```bash ```bash
python -m http.server 8000 python -m http.server 8000
# 第 1 页 http://localhost:8000/page1.html # 第 1 页 http://localhost:8000/page1.html
# 第 2 页 http://localhost:8000/page2.html
# 第 3 页 http://localhost:8000/page3.html # 第 3 页 http://localhost:8000/page3.html
``` ```
@@ -49,7 +53,8 @@ python -m http.server 8000
现有页面: 现有页面:
- `page1.html`:完整日落城市文字画,空白处铺淡色「天」。 - `page1.html`:完整日落城市文字画,空白处铺淡色「天」。
- `page3.html`:无「天」背景的演职员表;前景标题「演职员表」,背景为电线、麻雀、枪、草、消防栓与下半屏火山。 - `page2.html`:空白处铺淡色「墙」;底排「地」,其上以「桌」「椅」摆出桌子与椅子,桌面立「酒杯」「烛」与火,右下角「桶」包「酒」作酒桶,右上角「链」「笼」「灯」「穗」拼出灯笼,墙上以「框」包「图」作画框,前景为「酒水单」与两列曲目。
- `page3.html`:空白处铺淡色「黑」的演职员表;含 36 个演职员 nametag、电线、麻雀、草、消防栓与下半屏火山,nametag 与「演职员表」四周的空白格不铺背景。
共用与专属: 共用与专属:
@@ -60,22 +65,25 @@ python -m http.server 8000
## 导出 PDF ## 导出 PDF
使用 Playwright(由 uv 管理)导出,页面尺寸为 30em × 46em:28 × 44 格的画面,四周各留恰好 1em。 使用 Playwright(由 uv 管理)导出,页面尺寸为 30em × 46em:28 × 44 格的画面,四周各留恰好 1em。默认把三张页面合成一份三页 PDF。
```bash ```bash
uv sync uv sync
uv run python scripts/export_pdf.py # 默认导出 page1.html 到 dist/封面demo.pdf uv run python scripts/export_pdf.py # 三页 → dist/三页demo.pdf
uv run python scripts/export_pdf.py --page page3.html -o dist/page3.pdf uv run python scripts/export_pdf.py --page page2.html -o dist/page2.pdf
uv run python scripts/export_pdf.py --em 16 -o dist/封面demo-16.pdf uv run python scripts/export_pdf.py --pages page1.html page3.html -o dist/选页.pdf
uv run python scripts/export_pdf.py --em 16 -o dist/三页demo-16.pdf
``` ```
`--page` / `--pages` 按给定顺序逐页渲染后用 pypdf 合并,只传一个页面即得到单页 PDF。
脚本优先调用本机 Edge / Chrome;若都未安装,可先下载 Playwright Chromium: 脚本优先调用本机 Edge / Chrome;若都未安装,可先下载 Playwright Chromium:
```bash ```bash
uv run playwright install chromium uv run playwright install chromium
``` ```
导出页为黑底满幅,画面距上、下、左、右边界各 1em;脚本会在导出前校验该边距。默认 `--em 24` 时输出 720 × 1104 px(约 190 × 292 mm)。 导出页为黑底满幅,画面距上、下、左、右边界各 1em;脚本会在导出前逐页校验该边距。默认 `--em 24` 时每页输出 720 × 1104 px(约 190 × 292 mm)。
## 调整指南 ## 调整指南
@@ -108,6 +116,10 @@ uv run playwright install chromium
- 天线:`.ant`(深色款为 `.ant-dark`)。 - 天线:`.ant`(深色款为 `.ant-dark`)。
- 楼群:`.bld` 与 `.bld-1` 至 `.bld-8`。 - 楼群:`.bld` 与 `.bld-1` 至 `.bld-8`。
### 第 2 页(assets/js/page2.js)
`block(x, y, w, h, text, cls)` 按矩形铺字:桌面与桌腿、靠背/座面/椅腿、枪口/枪身/握把都由若干矩形拼出,坐标按格对齐;配色见 `page2.css` 的 `.table`、`.chair`、`.gun`、`.ground`。
### 第 3 页(assets/js/page3.js) ### 第 3 页(assets/js/page3.js)
通过 `glyphs` 数组放置任意背景字:`{ text, x, y, cls, vertical }`,坐标仍按格对齐。前景标题 `.credits` 由页面脚本追加,可在 `page3.css` 中调整字号与位置。 通过 `glyphs` 数组放置任意背景字:`{ text, x, y, cls, vertical }`,坐标仍按格对齐。前景标题 `.credits` 由页面脚本追加,可在 `page3.css` 中调整字号与位置。
+2 -1
View File
@@ -58,7 +58,8 @@ body {
.title-panel .title { .title-panel .title {
margin: 0; margin: 0;
color: #e6e6e6; /* 同 page2 节目列表、page3 人名的「小字」色;甲子仍为纯白 */
color: #e6dac6;
font-size: 1.8em; font-size: 1.8em;
font-weight: 400; font-weight: 400;
line-height: 1; line-height: 1;
+78
View File
@@ -0,0 +1,78 @@
/* 第 2 页:满铺淡色「墙」背景,明度同 page1 的「天」 */
.wall { color: rgba(190, 200, 240, 0.15); }
.ground { color: color-mix(in srgb, #8a7350 55%, #000); }
.table { color: color-mix(in srgb, #c08a4e 60%, #000); }
.chair { color: color-mix(in srgb, #d98f3f 65%, #000); }
.cup { color: color-mix(in srgb, #5fd08a 40%, #000); }
.candle { color: color-mix(in srgb, #efd9a8 65%, #000); }
.flame { color: color-mix(in srgb, #ff4b3a 80%, #000); }
.barrel { color: color-mix(in srgb, #a86a2e 58%, #000); }
.lid { color: color-mix(in srgb, #b03a2e 62%, #000); }
.wine { color: rgba(244, 238, 222, 0.5); }
.chain { color: color-mix(in srgb, #aeb4bd 62%, #000); }
.shade { color: color-mix(in srgb, #ef7d22 70%, #000); }
.cap { color: color-mix(in srgb, #ef7d22 70%, #000); }
.tassel { color: color-mix(in srgb, #d8382a 80%, #000); }
.core { color: #ffe23a; }
/* 墙上画框:偏橄榄的古铜金,避免与本页既有的橙、棕、亮黄重复;画面为灰白 */
.frame { color: color-mix(in srgb, #e0c34a 62%, #000); }
.picture { color: color-mix(in srgb, #e8e8e4 42%, #000); }
/* 画框照片:图区为 x13–19 / y4–8(6 × 4em),四边各缩 0.1em;等比 contain 后居中 */
.picture-img {
position: absolute;
left: 13em;
top: 4.1em;
width: 5.8em;
height: 3.8em;
object-fit: contain;
z-index: 1;
}
/* 前景标题「酒水单」:字号与颜色同 page3 的「演职员表」;
em 相对自身 1.6em 字号,2.9375em = 距顶 4.7 格(居中于留空行 4–6),
2.5em = 距左 4 格 */
.listing {
position: absolute;
top: 2.9375em;
left: 2.5em;
margin: 0;
color: #f2f2f2;
font-size: 1.6em;
font-weight: 400;
line-height: 1;
letter-spacing: 0;
white-space: nowrap;
z-index: 1;
}
/* 酒水单列表:前景两列,各自定位;
字号 1.25em,故 em = 格数 ÷ 1.25(如 12.25 格 = 9.8em) */
.program {
position: absolute;
inset: 0;
z-index: 1;
color: #e6dac6;
font-size: 1.25em;
line-height: 2;
}
.program-col { position: absolute; }
.program-col-1 { top: 10.2em; left: 4em; } /* 距顶 12.75 格、距左 5 格,居中于行 12–25 */
.program-col-2 { top: 11.8em; left: 13.6em; } /* 距顶 14.75 格、距左 17 格,居中于行 14–32 */
.program-item {
display: flex;
}
.program-no {
width: 2.6em;
text-align: left;
}
.program-name {
text-align: left;
white-space: nowrap;
}
+36 -13
View File
@@ -1,24 +1,26 @@
/* 第 3 页:无「天」背景;只保留电线、麻雀、枪、草与前景标题 */ /* 第 3 页:空白格铺淡色「黑」作墙;配色统一靠火山暖色:熔岩族为主,冷色只留电线一处,亮度整体收拢到 50–85% */
.wire { color: #8794a5; } /* 背景「黑」:与 page1「天」同明度的中性淡色 */
.sparrow { color: #ff7a3f; } .ink { color: rgba(200, 200, 208, 0.15); }
.gun { color: #8b6f55; }
.grass { color: #4fc353; } .wire { color: color-mix(in srgb, #8794a5 65%, #000); }
.hydrant { color: #a93b43; } .sparrow { color: color-mix(in srgb, #ff7a3f 85%, #000); }
/* 火山岩 / 熔岩参考色,按暗度 70% 压暗(保留 30% 明度) */ .grass { color: color-mix(in srgb, #7bc14f 72%, #000); }
.rock { color: color-mix(in srgb, #d07a38 30%, #000); } .hydrant { color: color-mix(in srgb, #e0362a 75%, #000); }
.lava-a { color: color-mix(in srgb, #ff8f3f 30%, #000); } /* 火山岩 / 熔岩参考色,按明度 50% 处理 */
.lava-b { color: color-mix(in srgb, #ff5a2a 30%, #000); } .rock { color: color-mix(in srgb, #d07a38 50%, #000); }
.lava-a { color: color-mix(in srgb, #ff8f3f 50%, #000); }
.lava-b { color: color-mix(in srgb, #ff5a2a 50%, #000); }
/* 前景标题:不参与网格,字号与位置自由; /* 前景标题:不参与网格,字号与位置自由;
top 的 em 相对自身 1.6em 字号,3.125em = 距顶部 5 个背景格 */ top 的 em 相对自身 1.6em 字号,2.9375em = 距顶部 4.7 格(居中于留空行 4–6) */
.credits { .credits {
position: absolute; position: absolute;
top: 3.125em; top: 2.9375em;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
margin: 0; margin: 0;
color: #f2f2f2; color: #f5efe4;
font-size: 1.6em; font-size: 1.6em;
font-weight: 400; font-weight: 400;
line-height: 1; line-height: 1;
@@ -26,3 +28,24 @@
white-space: nowrap; white-space: nowrap;
z-index: 1; z-index: 1;
} }
/* 演职员表 nametag:5 × 2 格,格内居中 */
.nametag {
position: absolute;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 5em;
height: 2em;
background: rgba(10, 8, 6, 0.54);
}
.nametag-name {
color: #e6dac6;
font-size: 1em;
font-weight: 400;
line-height: 1;
letter-spacing: 0;
white-space: nowrap;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+1 -3
View File
@@ -11,10 +11,9 @@
{ x: 4, y: 16, tone: 'rose' }, { x: 4, y: 16, tone: 'rose' },
]; ];
/* 落日:整体下移贴近楼群,形成日落; /* 落日:贴近楼群的日落圆盘,行宽按半径 8 的圆逐行取整(中心 x=14、y=26.5);
色调按数组顺序,顶部偏红 → 底部偏黄 */ 色调按数组顺序,顶部偏红 → 底部偏黄 */
const sun = [ const sun = [
{ y: 18, x: 13, w: 2 },
{ y: 19, x: 10, w: 8 }, { y: 19, x: 10, w: 8 },
{ y: 20, x: 9, w: 10 }, { y: 20, x: 9, w: 10 },
{ y: 21, x: 8, w: 12 }, { y: 21, x: 8, w: 12 },
@@ -30,7 +29,6 @@
{ y: 31, x: 8, w: 12 }, { y: 31, x: 8, w: 12 },
{ y: 32, x: 9, w: 10 }, { y: 32, x: 9, w: 10 },
{ y: 33, x: 10, w: 8 }, { y: 33, x: 10, w: 8 },
{ y: 34, x: 13, w: 2 },
]; ];
/* 飞鸟:数量减少,间距不等,仅少量成对;上下拉开分布 */ /* 飞鸟:数量减少,间距不等,仅少量成对;上下拉开分布 */
+142
View File
@@ -0,0 +1,142 @@
/* 第 2 页场景数据
背景字满铺「墙」,明度同 page1 的「天」;前景文字四周整格扣空。 */
(() => {
/* 按矩形铺字,逐行生成一条 glyph */
const block = (x, y, w, h, text, cls) =>
Array.from({ length: h }, (_, j) => ({
text: text.repeat(w),
x,
y: y + j,
cls,
}));
/* 扣空:按整格清空,保证留下的背景都是完整字 */
const clear = (x, y, w, h) => block(x, y, w, h, ' ', 'void');
TextScene.render({
cols: 28,
rows: 44,
skyChar: '墙',
skyClass: 'wall',
glyphs: [
/* 地:最后一排 */
{ text: '地'.repeat(28), x: 0, y: 43, cls: 'ground' },
/* 桌:12 格宽;桌面两行,桌腿各 2 格、向内缩一格并落地 */
...block(0, 36, 12, 2, '桌', 'table'),
...block(1, 38, 2, 5, '桌', 'table'),
...block(9, 38, 2, 5, '桌', 'table'),
/* 椅:4 格宽,比桌面低一档;右侧靠背、一格座面与两条椅腿 */
...block(16, 38, 1, 2, '椅', 'chair'),
...block(13, 40, 4, 1, '椅', 'chair'),
...block(13, 41, 1, 2, '椅', 'chair'),
...block(16, 41, 1, 2, '椅', 'chair'),
/* 酒杯:立在桌面之上,竖排两字;左起第 4 格与右起第 3 格 */
{ text: '酒', x: 3, y: 34, cls: 'cup' },
{ text: '杯', x: 3, y: 35, cls: 'cup' },
{ text: '酒', x: 9, y: 34, cls: 'cup' },
{ text: '杯', x: 9, y: 35, cls: 'cup' },
/* 烛:两酒杯正中竖排三字,立在桌面上 */
...block(6, 33, 1, 3, '烛', 'candle'),
/* 火:烛焰两字,左上到右下斜排,右下那字正对烛柱 */
{ text: '火', x: 5, y: 31, cls: 'flame' },
{ text: '火', x: 6, y: 32, cls: 'flame' },
/* 酒桶:右下角地上,4 格宽;「盖」为顶、「桶」做外壳,内里盛「酒」 */
...block(24, 38, 4, 1, '盖', 'lid'),
...block(24, 39, 1, 3, '桶', 'barrel'),
...block(27, 39, 1, 3, '桶', 'barrel'),
...block(25, 39, 2, 3, '酒', 'wine'),
...block(24, 42, 4, 1, '桶', 'barrel'),
/* 灯笼:右上角,4 格宽;链加长两节使整体下移两格,并整体右移一格 */
{ text: '链', x: 24, y: 0, cls: 'chain' },
{ text: '链', x: 24, y: 1, cls: 'chain' },
{ text: '链', x: 24, y: 2, cls: 'chain' },
...block(23, 3, 2, 1, '链', 'chain'),
{ text: '链', x: 23, y: 4, cls: 'chain' },
...block(23, 5, 2, 1, '笼', 'cap'),
...block(22, 6, 1, 3, '笼', 'shade'),
...block(25, 6, 1, 3, '笼', 'shade'),
...block(23, 6, 2, 3, '灯', 'core'),
...block(23, 9, 2, 1, '笼', 'shade'),
/* 穗:灯笼底下四字,上行两格、下行左右分开 */
...block(23, 10, 2, 1, '穗', 'tassel'),
{ text: '穗', x: 22, y: 11, cls: 'tassel' },
{ text: '穗', x: 24, y: 11, cls: 'tassel' },
/* 画框:墙上 8×6 矩形,「框」为边、「图」作画面 */
...block(12, 3, 8, 1, '框', 'frame'),
...block(12, 8, 8, 1, '框', 'frame'),
...block(12, 4, 1, 4, '框', 'frame'),
...block(19, 4, 1, 4, '框', 'frame'),
...block(13, 4, 6, 4, '图', 'picture'),
/* 前景留白:文字外框各放约半格,再向外取整格扣空,文字在其中居中 */
...clear(3, 4, 7, 3), /* 「酒水单」y4.7–6.3 → 行 4–6 */
...clear(4, 12, 8, 14), /* 第一列 y12.75–25.25 → 行 12–25 */
...clear(16, 14, 8, 19), /* 第二列 y14.75–32.25 → 行 14–32 */
],
});
const scene = document.querySelector('.scene');
/* 画框照片:盖在「图」区之上,四边各缩 0.1em 以免压到「框」 */
const picture = document.createElement('img');
picture.className = 'picture-img';
picture.src = 'assets/images/page2-picture.png';
picture.alt = '';
scene.append(picture);
const listing = document.createElement('h1');
listing.className = 'listing';
listing.textContent = '酒水单';
scene.append(listing);
/* 酒水单:前景两列,前 5 后 7;罗马数字与曲目名各自左对齐 */
const PROGRAM = [
['I', '听'], ['II', '酒歌'], ['III', '眼睛'], ['IV', '黑'], ['V', '天'],
['VI', '麻雀'], ['VII', '业火'], ['VIII', '问'], ['IX', '难'], ['X', '画'],
['XI', '田'], ['XII', '今生'],
];
const programColumn = (items) => {
const col = document.createElement('div');
col.className = 'program-col';
for (const [no, name] of items) {
const row = document.createElement('div');
row.className = 'program-item';
const numeral = document.createElement('span');
numeral.className = 'program-no';
numeral.textContent = no;
const label = document.createElement('span');
label.className = 'program-name';
label.textContent = name;
row.append(numeral, label);
col.append(row);
}
return col;
};
const program = document.createElement('div');
program.className = 'program';
const first = programColumn(PROGRAM.slice(0, 5));
const second = programColumn(PROGRAM.slice(5));
first.classList.add('program-col-1');
second.classList.add('program-col-2');
program.append(first, second);
scene.append(program);
})();
+75 -14
View File
@@ -1,6 +1,6 @@
/* 第 3 页场景数据 /* 第 3 页场景数据
前景:演职员表;背景:电线、麻雀、枪、草。 前景:演职员表与 nametag;背景:电线、麻雀、草、火山。
本页不铺「天」,空白格保持为空。 */ 空白格铺淡色「黑」,nametag 与标题四周的空白格保持留空。 */
(() => { (() => {
/* 固定种子的噪声,保证每次渲染一致 */ /* 固定种子的噪声,保证每次渲染一致 */
@@ -93,27 +93,67 @@
cls: 'lava-b', cls: 'lava-b',
}); });
/* 扣空:整格留白,供前景文字与 nametag 避让(先扣空,其余背景字仍可覆盖) */
const clear = (x, y, w, h) =>
Array.from({ length: h }, (_, j) => ({
text: ' '.repeat(w),
x,
y: y + j,
cls: 'void',
}));
const NAMES = [
'郑滕飞', '秦雪莹', '关宸宇', '王天晓', '谢雨舟', '储明威', '庞熙若',
'刘婉莹', '陈彦淇', '古雅文', '周胤伯', '郑佳欣', '娄敬庭', '张加弛',
'李昀芷', '吴红霓', '鲍馨仪', '刘润泽', '李若熙', '马希予', '袁立桓',
'蒋子模', '李有睿', '王海心', '朱丽烨', '朱彦阳', '汪楚钧', '吴玥',
'张梓轩', '罗午阳', '郭柯言', '王之饶', '应玥', '湛茜茜', '黄钰涵',
'虞韫嘉',
];
const TAG = {
cols: 4,
rows: 9,
width: 5,
height: 2,
gapX: 1,
gapY: 1,
left: 2,
top: 11,
};
/* 每个牌子占 5 × 2 格 */
const tagClear = [];
for (let row = 0; row < TAG.rows; row++) {
for (let col = 0; col < TAG.cols; col++) {
tagClear.push(
...clear(
TAG.left + col * (TAG.width + TAG.gapX),
TAG.top + row * (TAG.height + TAG.gapY),
TAG.width,
TAG.height
)
);
}
}
TextScene.render({ TextScene.render({
cols: 28, cols: 28,
rows: 44, rows: 44,
skyChar: '', skyChar: '黑',
skyClass: 'ink',
glyphs: [ glyphs: [
/* 前景避让:演职员表(x10.8–17.2 / y4.7–6.3)四周留约半格,再是全部牌子 */
...clear(9, 4, 10, 3),
...tagClear,
/* 电线:整行贯穿页面 */ /* 电线:整行贯穿页面 */
{ text: '电线'.repeat(14), x: 0, y: 8, cls: 'wire' }, { text: '电线'.repeat(14), x: 0, y: 8, cls: 'wire' },
/* 麻雀:竖排于从左到右第四格,落在电线上方 */ /* 麻雀:竖排于从左到右第四格,落在电线上方 */
{ text: '麻雀', x: 3, y: 6, cls: 'sparrow', vertical: true }, { text: '麻雀', x: 3, y: 6, cls: 'sparrow', vertical: true },
/* 火山铺在底部背景,枪、草、消防栓在其上层 */ /* 火山铺在底部背景,草、消防栓在其上层 */
...volcano, ...volcano,
/* 枪:多个「枪」拼出枪形,占最右 4 列,枪口指向左上方的麻雀 */ /* 草:最底部一排;第 1、8、15、27 列加高一格 */
{ text: '枪', x: 24, y: 20, cls: 'gun' }, ...[0, 7, 14, 26].map((x) => ({ text: '草', x, y: 42, cls: 'grass' })),
{ text: '枪', x: 25, y: 20, cls: 'gun' },
{ text: '枪', x: 25, y: 21, cls: 'gun' },
{ text: '枪', x: 26, y: 21, cls: 'gun' },
{ text: '枪', x: 26, y: 22, cls: 'gun' },
{ text: '枪', x: 27, y: 22, cls: 'gun' },
{ text: '枪', x: 27, y: 23, cls: 'gun' },
{ text: '枪', x: 27, y: 24, cls: 'gun' },
/* 草:最底部一排 */
{ text: '草'.repeat(28), x: 0, y: 43, cls: 'grass' }, { text: '草'.repeat(28), x: 0, y: 43, cls: 'grass' },
/* 消防栓:最后三行分散竖排九根,左侧留 2 格 */ /* 消防栓:最后三行分散竖排九根,左侧留 2 格 */
...[2, 5, 8, 10, 13, 16, 19, 23, 25].map((x) => ({ ...[2, 5, 8, 10, 13, 16, 19, 23, 25].map((x) => ({
@@ -131,4 +171,25 @@
credits.className = 'credits'; credits.className = 'credits';
credits.textContent = '演职员表'; credits.textContent = '演职员表';
scene.append(credits); scene.append(credits);
/* 36 个 nametag:4 列 × 9 行,补满 */
let nameIndex = 0;
for (let row = 0; row < TAG.rows; row++) {
for (let col = 0; col < TAG.cols; col++) {
const name = NAMES[nameIndex++];
if (!name) break;
const tag = document.createElement('div');
tag.className = 'nametag';
tag.style.left = `${TAG.left + col * (TAG.width + TAG.gapX)}em`;
tag.style.top = `${TAG.top + row * (TAG.height + TAG.gapY)}em`;
const label = document.createElement('span');
label.className = 'nametag-name';
label.textContent = name;
tag.append(label);
scene.append(tag);
}
}
})(); })();
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="color-scheme" content="dark">
<title>桌椅与枪</title>
<link rel="stylesheet" href="assets/css/common.css">
<link rel="stylesheet" href="assets/css/page2.css">
<script src="assets/js/text-scene.js" defer></script>
<script src="assets/js/page2.js" defer></script>
</head>
<body>
<div class="scene"></div>
</body>
</html>
+1
View File
@@ -5,6 +5,7 @@ description = "落日文字画页面与 PDF 导出工具"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"playwright>=1.48", "playwright>=1.48",
"pypdf>=7.0.1",
] ]
[tool.uv] [tool.uv]
+36
View File
@@ -0,0 +1,36 @@
郑滕飞
秦雪莹
关宸宇
王天晓
谢雨舟
储明威
庞熙若
刘婉莹
陈彦淇
古雅文
周胤伯
郑佳欣
娄敬庭
张加弛
李昀芷
吴红霓
鲍馨仪
刘润泽
李若熙
马希予
袁立桓
蒋子模
李有睿
王海心
朱丽烨
朱彦阳
汪楚钧
吴玥
张梓轩
罗午阳
郭柯言
王之饶
应玥
湛茜茜
黄钰涵
虞韫嘉
+12
View File
@@ -0,0 +1,12 @@
I 听
II 酒歌
III 眼睛
IV 黑
V 天
VI 麻雀
VII 业火
VIII 问
IX 难
X 画
XI 田
XII 今生
+54 -21
View File
@@ -3,24 +3,30 @@
页面为 28 × 44 格文字画,1em 即一个汉字格。导出页尺寸为 30em × 46em, 页面为 28 × 44 格文字画,1em 即一个汉字格。导出页尺寸为 30em × 46em,
场景四周各留 1em;底色铺满整页。 场景四周各留 1em;底色铺满整页。
默认把 page1、page2、page3 依次合成一份三页 PDF。
用法: 用法:
uv run python scripts/export_pdf.py uv run python scripts/export_pdf.py # 三页 → dist/三页demo.pdf
uv run python scripts/export_pdf.py --page page2.html -o dist/page2.pdf uv run python scripts/export_pdf.py --page page2.html -o dist/page2.pdf
uv run python scripts/export_pdf.py --em 16 -o dist/封面demo-16.pdf uv run python scripts/export_pdf.py --pages page1.html page3.html -o dist/选页.pdf
uv run python scripts/export_pdf.py --em 16 -o dist/三页demo-16.pdf
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import io
import pathlib import pathlib
from playwright.sync_api import sync_playwright from playwright.sync_api import sync_playwright
from pypdf import PdfWriter
ROOT = pathlib.Path(__file__).resolve().parents[1] ROOT = pathlib.Path(__file__).resolve().parents[1]
COLS = 28 COLS = 28
ROWS = 44 ROWS = 44
MARGIN_EM = 1 MARGIN_EM = 1
DEFAULT_PAGES = ("page1.html", "page2.html", "page3.html")
def export_css(em: float) -> tuple[str, float, float]: def export_css(em: float) -> tuple[str, float, float]:
@@ -71,19 +77,17 @@ def launch_browser(pw):
) )
def export(em: float, out: pathlib.Path, html: pathlib.Path) -> None: def render_pdf(browser, html, css, em, page_w, page_h):
css, page_w, page_h = export_css(em) """渲染单页并返回 PDF 字节;导出前校验画面四边恰好留 1em。"""
with sync_playwright() as pw:
browser, browser_name = launch_browser(pw)
page = browser.new_page( page = browser.new_page(
viewport={"width": round(page_w), "height": round(page_h)}, viewport={"width": round(page_w), "height": round(page_h)},
device_scale_factor=1, device_scale_factor=1,
) )
try:
page.goto(html.as_uri(), wait_until="load") page.goto(html.as_uri(), wait_until="load")
page.evaluate("() => document.fonts.ready") page.evaluate("() => document.fonts.ready")
if not page.evaluate("document.fonts.check('16px Zpix')"): if not page.evaluate("document.fonts.check('16px Zpix')"):
raise SystemExit("Zpix 字体未加载,无法导出。") raise SystemExit(f"{html.name}:Zpix 字体未加载,无法导出。")
page.add_style_tag(content=css) page.add_style_tag(content=css)
page.wait_for_timeout(200) page.wait_for_timeout(200)
@@ -106,32 +110,56 @@ def export(em: float, out: pathlib.Path, html: pathlib.Path) -> None:
or abs(metrics["h"] - ROWS * em) > 0.5 or abs(metrics["h"] - ROWS * em) > 0.5
or any(v != expected_pad for v in metrics["pad"]) or any(v != expected_pad for v in metrics["pad"])
): ):
raise SystemExit(f"导出布局校验失败:{metrics}") raise SystemExit(f"{html.name}:导出布局校验失败:{metrics}")
out.parent.mkdir(parents=True, exist_ok=True) return page.pdf(
page.pdf(
path=str(out),
width=f"{page_w:g}px", width=f"{page_w:g}px",
height=f"{page_h:g}px", height=f"{page_h:g}px",
margin={"top": "0", "right": "0", "bottom": "0", "left": "0"}, margin={"top": "0", "right": "0", "bottom": "0", "left": "0"},
print_background=True, print_background=True,
prefer_css_page_size=True, prefer_css_page_size=True,
) )
finally:
page.close()
def export(htmls, out, em):
css, page_w, page_h = export_css(em)
with sync_playwright() as pw:
browser, browser_name = launch_browser(pw)
blobs = [render_pdf(browser, html, css, em, page_w, page_h) for html in htmls]
browser.close() browser.close()
writer = PdfWriter()
for blob in blobs:
writer.append(io.BytesIO(blob))
out.parent.mkdir(parents=True, exist_ok=True)
with out.open("wb") as fh:
writer.write(fh)
writer.close()
mm_w = page_w / 96 * 25.4 mm_w = page_w / 96 * 25.4
mm_h = page_h / 96 * 25.4 mm_h = page_h / 96 * 25.4
print(f"浏览器:{browser_name}") print(f"浏览器:{browser_name}")
print(f"页面:{page_w:g} × {page_h:g}px(约 {mm_w:.0f} × {mm_h:.0f} mm),四边各 {em:g}px(1em)") print(f"页面:{page_w:g} × {page_h:g}px(约 {mm_w:.0f} × {mm_h:.0f} mm),四边各 {em:g}px(1em)")
print(f"已导出:{out.relative_to(ROOT)}") for index, html in enumerate(htmls, start=1):
print(f" 第 {index} 页:{html.name}")
print(f"已导出 {len(htmls)} 页:{out.relative_to(ROOT)}")
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(description="将文字画页面导出为 PDF(四周各留 1em)") parser = argparse.ArgumentParser(
description="将文字画页面导出为 PDF(四周各留 1em,可多页合一)"
)
parser.add_argument( parser.add_argument(
"--pages",
"--page", "--page",
default="page1.html", dest="pages",
help="要导出的 HTML 文件,默认 page1.html", nargs="+",
default=list(DEFAULT_PAGES),
help="要导出的 HTML,按顺序合成多页;默认 page1 page2 page3",
) )
parser.add_argument( parser.add_argument(
"--em", "--em",
@@ -143,16 +171,21 @@ def main() -> None:
"-o", "-o",
"--out", "--out",
type=pathlib.Path, type=pathlib.Path,
default=ROOT / "dist" / "封面demo.pdf", default=ROOT / "dist" / "三页demo.pdf",
help="输出文件路径,默认 dist/封面demo.pdf", help="输出文件路径,默认 dist/三页demo.pdf",
) )
args = parser.parse_args() args = parser.parse_args()
if args.em <= 0: if args.em <= 0:
parser.error("--em 必须大于 0") parser.error("--em 必须大于 0")
html = (ROOT / args.page).resolve()
htmls = []
for name in args.pages:
html = (ROOT / name).resolve()
if not html.is_file(): if not html.is_file():
parser.error(f"页面不存在:{args.page}") parser.error(f"页面不存在:{name}")
export(args.em, args.out.resolve(), html) htmls.append(html)
export(htmls, args.out.resolve(), args.em)
if __name__ == "__main__": if __name__ == "__main__":
Generated
+26 -1
View File
@@ -94,10 +94,14 @@ version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "playwright" }, { name = "playwright" },
{ name = "pypdf" },
] ]
[package.metadata] [package.metadata]
requires-dist = [{ name = "playwright", specifier = ">=1.48" }] requires-dist = [
{ name = "playwright", specifier = ">=1.48" },
{ name = "pypdf", specifier = ">=7.0.1" },
]
[[package]] [[package]]
name = "playwright" name = "playwright"
@@ -117,3 +121,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/0d/32e96514b48fabd16ece31bae11afba5e18cf425123fb7c36c6b8bd8db8f/playwright-1.65.0-py3-none-win_amd64.whl", hash = "sha256:ab22611318e8747477c949c81b055ee6e224ecedf66c2a794209c7fd0210a743", size = 42664080, upload-time = "2026-11-09T21:09:54.425Z" }, { url = "https://files.pythonhosted.org/packages/8c/0d/32e96514b48fabd16ece31bae11afba5e18cf425123fb7c36c6b8bd8db8f/playwright-1.65.0-py3-none-win_amd64.whl", hash = "sha256:ab22611318e8747477c949c81b055ee6e224ecedf66c2a794209c7fd0210a743", size = 42664080, upload-time = "2026-11-09T21:09:54.425Z" },
{ url = "https://files.pythonhosted.org/packages/ac/d6/2a75d6eeda7b23e1b241ef898ed53bc3ea134ff52d2c959c770dce843e3c/playwright-1.65.0-py3-none-win_arm64.whl", hash = "sha256:67f0b1a80d0d2d4349f67c1ccfeceaa067f1d85aaac32d4d81dd3e971a96ddbb", size = 38367903, upload-time = "2026-11-09T21:09:58.28Z" }, { url = "https://files.pythonhosted.org/packages/ac/d6/2a75d6eeda7b23e1b241ef898ed53bc3ea134ff52d2c959c770dce843e3c/playwright-1.65.0-py3-none-win_arm64.whl", hash = "sha256:67f0b1a80d0d2d4349f67c1ccfeceaa067f1d85aaac32d4d81dd3e971a96ddbb", size = 38367903, upload-time = "2026-11-09T21:09:58.28Z" },
] ]
[[package]]
name = "pypdf"
version = "7.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/c2/4c34b3229abae3e2fcc7809e6cf80fdea159771a37142f475fbf1009c809/pypdf-7.0.1.tar.gz", hash = "sha256:0a78bb99efb41e9088260e9a8e5da134a5cbb9a322014744177bf29b5267ea6d", size = 7206748, upload-time = "2026-11-10T11:47:22.582Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/a9/ae11deef21eaac5113f8c3848da1c42423b7abeca0abc349bfec7abf4c4e/pypdf-7.0.1-py3-none-any.whl", hash = "sha256:6131b764b046282357037309e7ef2a1b7679d4a5c53eed3db2bfbf7c673ddc2b", size = 416136, upload-time = "2026-11-10T11:47:20.716Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]