9 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
14 changed files with 425 additions and 70 deletions

No files matched your search

+3
View File
@@ -12,6 +12,9 @@ desktop.ini
*.log
.tmp-*
# 图片本地备份(不入库)
*.orig.png
# Python / uv
.venv/
__pycache__/
+19 -7
View File
@@ -2,7 +2,7 @@
一个 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 页入口
├─ page2.html # 第 2 页入口
├─ page3.html # 第 3 页入口
├─ pyproject.toml # uv 环境与导出依赖
├─ uv.lock # 依赖锁定
@@ -25,10 +26,12 @@
│ ├─ css/
│ │ ├─ common.css # 三页共用:字体、骨架、网格、标题框
│ │ ├─ page1.css # 第 1 页配色
│ │ ├─ page2.css # 第 2 页配色
│ │ └─ page3.css # 第 3 页配色与前景标题
│ ├─ js/
│ │ ├─ text-scene.js # 共享文字画渲染引擎
│ │ ├─ page1.js # 第 1 页场景数据
│ │ ├─ page2.js # 第 2 页场景数据
│ │ └─ page3.js # 第 3 页场景数据
│ └─ fonts/zpix.ttf # Zpix 像素字体
└─ references/a.html # 原始参考页
@@ -41,6 +44,7 @@
```bash
python -m http.server 8000
# 第 1 页 http://localhost:8000/page1.html
# 第 2 页 http://localhost:8000/page2.html
# 第 3 页 http://localhost:8000/page3.html
```
@@ -49,7 +53,8 @@ python -m http.server 8000
现有页面:
- `page1.html`:完整日落城市文字画,空白处铺淡色「天」。
- `page3.html`:无「天」背景的演职员表;含 35 个演职员 nametag、电线、麻雀、草、消防栓与下半屏火山。
- `page2.html`:空白处铺淡色「墙」;底排「地」,其上以「桌」「椅」摆出桌子与椅子,桌面立「酒杯」「烛」与火,右下角「桶」包「酒」作酒桶,右上角「链」「笼」「灯」「穗」拼出灯笼,墙上以「框」包「图」作画框,前景为「酒水单」与两列曲目。
- `page3.html`:空白处铺淡色「黑」的演职员表;含 36 个演职员 nametag、电线、麻雀、草、消防栓与下半屏火山,nametag 与「演职员表」四周的空白格不铺背景。
共用与专属:
@@ -60,22 +65,25 @@ python -m http.server 8000
## 导出 PDF
使用 Playwright(由 uv 管理)导出,页面尺寸为 30em × 46em:28 × 44 格的画面,四周各留恰好 1em。
使用 Playwright(由 uv 管理)导出,页面尺寸为 30em × 46em:28 × 44 格的画面,四周各留恰好 1em。默认把三张页面合成一份三页 PDF。
```bash
uv sync
uv run python scripts/export_pdf.py # 默认导出 page1.html 到 dist/封面demo.pdf
uv run python scripts/export_pdf.py --page page3.html -o dist/page3.pdf
uv run python scripts/export_pdf.py --em 16 -o dist/封面demo-16.pdf
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 --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:
```bash
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`)。
- 楼群:`.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)
通过 `glyphs` 数组放置任意背景字:`{ text, x, y, cls, vertical }`,坐标仍按格对齐。前景标题 `.credits` 由页面脚本追加,可在 `page3.css` 中调整字号与位置。
+2 -1
View File
@@ -58,7 +58,8 @@ body {
.title-panel .title {
margin: 0;
color: #e6e6e6;
/* 同 page2 节目列表、page3 人名的「小字」色;甲子仍为纯白 */
color: #e6dac6;
font-size: 1.8em;
font-weight: 400;
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;
}
+11 -8
View File
@@ -1,23 +1,26 @@
/* 第 3 页:无「天」背景;只保留电线、麻雀、草与前景标题 */
/* 第 3 页:空白格铺淡色「黑」作墙;配色统一靠火山暖色:熔岩族为主,冷色只留电线一处,亮度整体收拢到 50–85% */
.wire { color: #8794a5; }
.sparrow { color: #ff7a3f; }
.grass { color: #4fc353; }
.hydrant { color: #a93b43; }
/* 背景「黑」:与 page1「天」同明度的中性淡色 */
.ink { color: rgba(200, 200, 208, 0.15); }
.wire { color: color-mix(in srgb, #8794a5 65%, #000); }
.sparrow { color: color-mix(in srgb, #ff7a3f 85%, #000); }
.grass { color: color-mix(in srgb, #7bc14f 72%, #000); }
.hydrant { color: color-mix(in srgb, #e0362a 75%, #000); }
/* 火山岩 / 熔岩参考色,按明度 50% 处理 */
.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 {
position: absolute;
top: 3.125em;
top: 2.9375em;
left: 50%;
transform: translateX(-50%);
margin: 0;
color: #f2f2f2;
color: #f5efe4;
font-size: 1.6em;
font-weight: 400;
line-height: 1;
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' },
];
/* 落日:整体下移贴近楼群,形成日落;
/* 落日:贴近楼群的日落圆盘,行宽按半径 8 的圆逐行取整(中心 x=14、y=26.5);
色调按数组顺序,顶部偏红 → 底部偏黄 */
const sun = [
{ y: 18, x: 13, w: 2 },
{ y: 19, x: 10, w: 8 },
{ y: 20, x: 9, w: 10 },
{ y: 21, x: 8, w: 12 },
@@ -30,7 +29,6 @@
{ y: 31, x: 8, w: 12 },
{ y: 32, x: 9, w: 10 },
{ 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);
})();
+53 -24
View File
@@ -1,6 +1,6 @@
/* 第 3 页场景数据
前景:演职员表;背景:电线、麻雀、草。
本页不铺「天」,空白格保持为空。 */
前景:演职员表与 nametag;背景:电线、麻雀、草、火山。
空白格铺淡色「黑」,nametag 与标题四周的空白格保持留空。 */
(() => {
/* 固定种子的噪声,保证每次渲染一致 */
@@ -93,18 +93,67 @@
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({
cols: 28,
rows: 44,
skyChar: '',
skyChar: '黑',
skyClass: 'ink',
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: '麻雀', x: 3, y: 6, cls: 'sparrow', vertical: true },
/* 火山铺在底部背景,草、消防栓在其上层 */
...volcano,
/* 草:最底部一排 */
/* 草:最底部一排;第 1、8、15、27 列加高一格 */
...[0, 7, 14, 26].map((x) => ({ text: '草', x, y: 42, cls: 'grass' })),
{ text: '草'.repeat(28), x: 0, y: 43, cls: 'grass' },
/* 消防栓:最后三行分散竖排九根,左侧留 2 格 */
...[2, 5, 8, 10, 13, 16, 19, 23, 25].map((x) => ({
@@ -124,26 +173,6 @@
scene.append(credits);
/* 36 个 nametag:4 列 × 9 行,补满 */
const NAMES = [
'郑滕飞', '秦雪莹', '关宸宇', '王天晓', '谢雨舟', '储明威', '庞熙若',
'刘婉莹', '陈彦淇', '古雅文', '周胤伯', '郑佳欣', '娄敬庭', '张加弛',
'李昀芷', '吴红霓', '鲍馨仪', '刘润泽', '李若熙', '马希予', '袁立桓',
'蒋子模', '李有睿', '王海心', '朱丽烨', '朱彦阳', '汪楚钧', '吴玥',
'张梓轩', '罗午阳', '郭柯言', '王之饶', '应玥', '湛茜茜', '黄钰涵',
'虞韫嘉',
];
const TAG = {
cols: 4,
rows: 9,
width: 5,
height: 2,
gapX: 1,
gapY: 1,
left: 2,
top: 12,
};
let nameIndex = 0;
for (let row = 0; row < TAG.rows; row++) {
for (let col = 0; col < TAG.cols; col++) {
+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"
dependencies = [
"playwright>=1.48",
"pypdf>=7.0.1",
]
[tool.uv]
+12
View File
@@ -0,0 +1,12 @@
I 听
II 酒歌
III 眼睛
IV 黑
V 天
VI 麻雀
VII 业火
VIII 问
IX 难
X 画
XI 田
XII 今生
+59 -26
View File
@@ -3,24 +3,30 @@
页面为 28 × 44 格文字画,1em 即一个汉字格。导出页尺寸为 30em × 46em,
场景四周各留 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 --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
import argparse
import io
import pathlib
from playwright.sync_api import sync_playwright
from pypdf import PdfWriter
ROOT = pathlib.Path(__file__).resolve().parents[1]
COLS = 28
ROWS = 44
MARGIN_EM = 1
DEFAULT_PAGES = ("page1.html", "page2.html", "page3.html")
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:
css, page_w, page_h = export_css(em)
with sync_playwright() as pw:
browser, browser_name = launch_browser(pw)
page = browser.new_page(
viewport={"width": round(page_w), "height": round(page_h)},
device_scale_factor=1,
)
def render_pdf(browser, html, css, em, page_w, page_h):
"""渲染单页并返回 PDF 字节;导出前校验画面四边恰好留 1em。"""
page = browser.new_page(
viewport={"width": round(page_w), "height": round(page_h)},
device_scale_factor=1,
)
try:
page.goto(html.as_uri(), wait_until="load")
page.evaluate("() => document.fonts.ready")
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.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 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)
page.pdf(
path=str(out),
return page.pdf(
width=f"{page_w:g}px",
height=f"{page_h:g}px",
margin={"top": "0", "right": "0", "bottom": "0", "left": "0"},
print_background=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()
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_h = page_h / 96 * 25.4
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"已导出:{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:
parser = argparse.ArgumentParser(description="将文字画页面导出为 PDF(四周各留 1em)")
parser = argparse.ArgumentParser(
description="将文字画页面导出为 PDF(四周各留 1em,可多页合一)"
)
parser.add_argument(
"--pages",
"--page",
default="page1.html",
help="要导出的 HTML 文件,默认 page1.html",
dest="pages",
nargs="+",
default=list(DEFAULT_PAGES),
help="要导出的 HTML,按顺序合成多页;默认 page1 page2 page3",
)
parser.add_argument(
"--em",
@@ -143,16 +171,21 @@ def main() -> None:
"-o",
"--out",
type=pathlib.Path,
default=ROOT / "dist" / "封面demo.pdf",
help="输出文件路径,默认 dist/封面demo.pdf",
default=ROOT / "dist" / "三页demo.pdf",
help="输出文件路径,默认 dist/三页demo.pdf",
)
args = parser.parse_args()
if args.em <= 0:
parser.error("--em 必须大于 0")
html = (ROOT / args.page).resolve()
if not html.is_file():
parser.error(f"页面不存在:{args.page}")
export(args.em, args.out.resolve(), html)
htmls = []
for name in args.pages:
html = (ROOT / name).resolve()
if not html.is_file():
parser.error(f"页面不存在:{name}")
htmls.append(html)
export(htmls, args.out.resolve(), args.em)
if __name__ == "__main__":
Generated
+26 -1
View File
@@ -94,10 +94,14 @@ version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "playwright" },
{ name = "pypdf" },
]
[package.metadata]
requires-dist = [{ name = "playwright", specifier = ">=1.48" }]
requires-dist = [
{ name = "playwright", specifier = ">=1.48" },
{ name = "pypdf", specifier = ">=7.0.1" },
]
[[package]]
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/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" },
]