From 319ba1890aa37d820112718d5c87b1aa29f33a13 Mon Sep 17 00:00:00 2001 From: rogerwang2008 Date: Wed, 11 Nov 2026 00:13:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20page3=20=E6=BC=94?= =?UTF-8?q?=E8=81=8C=E5=91=98=E8=A1=A8=E5=B9=B6=E6=8B=86=E5=88=86=E5=A4=9A?= =?UTF-8?q?=E9=A1=B5=E8=B5=84=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 page3.html:无「天」背景的演职员表与火山场景 - 重构为页面专属 CSS/JS 与共用的 text-scene 渲染引擎 - PDF 导出支持 --page 参数 --- README.md | 62 +++++++--- assets/css/common.css | 69 +++++++++++ assets/css/{style.css => page1.css} | 68 +---------- assets/css/page3.css | 28 +++++ assets/js/page1.js | 87 ++++++++++++++ assets/js/page3.js | 134 ++++++++++++++++++++++ assets/js/scene.js | 170 ---------------------------- assets/js/text-scene.js | 137 ++++++++++++++++++++++ index.html => page1.html | 6 +- page3.html | 18 +++ scripts/export_pdf.py | 18 ++- 11 files changed, 539 insertions(+), 258 deletions(-) create mode 100644 assets/css/common.css rename assets/css/{style.css => page1.css} (53%) create mode 100644 assets/css/page3.css create mode 100644 assets/js/page1.js create mode 100644 assets/js/page3.js delete mode 100644 assets/js/scene.js create mode 100644 assets/js/text-scene.js rename index.html => page1.html (58%) create mode 100644 page3.html diff --git a/README.md b/README.md index 12d730d..153625b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # 《关于「甲子」的虚构故事》公众号推送 Demo -一个 28 列 × 44 行的汉字文字画单页。黑底、每格一个汉字,空白处由淡色「天」铺满;画面包含晚霞云、落日、飞鸟、天线与城市楼群,上方以整格扣出的黑色标题框居中呈现「关于「甲子」的虚构故事」。 +一个 28 列 × 44 行的汉字文字画 Demo。黑底、每格一个汉字,空白处由淡色「天」铺满;画面包含晚霞云、落日、飞鸟、天线与城市楼群,上方以整格扣出的黑色标题框居中呈现「关于「甲子」的虚构故事」。 + +项目规划为三张类似页面,当前实现第 1 页(`page1.html`)与第 3 页(`page3.html`)。 ## 效果概述 @@ -14,33 +16,56 @@ ``` . -├─ index.html # 页面骨架 +├─ page1.html # 第 1 页入口 +├─ page3.html # 第 3 页入口 ├─ pyproject.toml # uv 环境与导出依赖 ├─ uv.lock # 依赖锁定 ├─ scripts/export_pdf.py # PDF 导出脚本 ├─ assets/ -│ ├─ css/style.css # 布局、字号与各元素配色 -│ ├─ js/scene.js # 场景数据与网格渲染 +│ ├─ css/ +│ │ ├─ common.css # 三页共用:字体、骨架、网格、标题框 +│ │ ├─ page1.css # 第 1 页配色 +│ │ └─ page3.css # 第 3 页配色与前景标题 +│ ├─ js/ +│ │ ├─ text-scene.js # 共享文字画渲染引擎 +│ │ ├─ page1.js # 第 1 页场景数据 +│ │ └─ page3.js # 第 3 页场景数据 │ └─ fonts/zpix.ttf # Zpix 像素字体 └─ references/a.html # 原始参考页 ``` ## 运行 -直接打开 `index.html` 即可。若需通过本地服务器预览: +直接打开 `page1.html` 即可。若需通过本地服务器预览: ```bash python -m http.server 8000 -# 浏览器访问 http://localhost:8000 +# 第 1 页 http://localhost:8000/page1.html +# 第 3 页 http://localhost:8000/page3.html ``` +## 多页结构 + +现有页面: + +- `page1.html`:完整日落城市文字画,空白处铺淡色「天」。 +- `page3.html`:无「天」背景的演职员表;前景标题「演职员表」,背景为电线、麻雀、枪、草、消防栓与下半屏火山。 + +共用与专属: + +- `common.css`、`text-scene.js`:三页共用的页面骨架、网格、标题框与渲染引擎。 +- `pageN.html`、`pageN.css`、`pageN.js`:每一页的入口、配色与场景数据。 + +新增页面时复制 `page1.*` 三件套,修改 `pageN.js` 中的数据与 `pageN.css` 配色,并按顺序引入 `common.css`、`pageN.css`、`text-scene.js`、`pageN.js`。 + ## 导出 PDF 使用 Playwright(由 uv 管理)导出,页面尺寸为 30em × 46em:28 × 44 格的画面,四周各留恰好 1em。 ```bash uv sync -uv run python scripts/export_pdf.py # 默认 1em = 24px,输出 dist/封面demo.pdf +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 ``` @@ -50,11 +75,11 @@ uv run python scripts/export_pdf.py --em 16 -o dist/封面demo-16.pdf uv run playwright install chromium ``` -导出页为黑底满幅,画面距上、下、左、右边界各 1em;脚本会在导出前校验该边距。默认 `--em 24` 时输出 720 × 1104px(约 190 × 292 mm)。 +导出页为黑底满幅,画面距上、下、左、右边界各 1em;脚本会在导出前校验该边距。默认 `--em 24` 时输出 720 × 1104 px(约 190 × 292 mm)。 ## 调整指南 -### 构图(assets/js/scene.js) +### 构图(assets/js/page1.js) | 数据 | 含义 | | --- | --- | @@ -63,24 +88,33 @@ uv run playwright install chromium | `birds` | 飞鸟:每只占 2 格(麻雀 / 喜鹊 / 乌鸦) | | `antennas` | 天线:单列「线」,从 y 向下叠 h 格 | | `buildings` | 楼群:x/y/w/h,n 对应 `.bld-n` | -| `TITLE` | 标题文案、起始行、行高、左右留格数 | +| `title` | 标题文案、高亮词、起始行、行高、左右留格数 | 坐标为 0–27 列、0–43 行;越界会直接抛错提示。 -### 样式(assets/css/style.css) +### 样式 + +共用样式(`assets/css/common.css`): - 整体字号:`.scene` 的 `font-size`,所有元素统一继承。 +- 标题:`.title-panel`(黑色矩形)与 `.title-panel .title`(文字样式)。 + +页面样式(`assets/css/page1.css`): + - 背景「天」:`.sky`。 - 云:`.cloud` 的明度与 `.cloud-*` 的色相。 -- 落日:`.sun` 的整体明度(当前为原色 90%),`.sun-t1` 至 `.sun-t17` 为逐行色阶。 +- 落日:`.sun` 的整体明度(当前为原色 80%),`.sun-t1` 至 `.sun-t17` 为逐行色阶。 - 飞鸟:`.bird-sparrow`、`.bird-magpie`、`.bird-crow`。 - 天线:`.ant`(深色款为 `.ant-dark`)。 - 楼群:`.bld` 与 `.bld-1` 至 `.bld-8`。 -- 标题:`.title-panel`(黑色矩形)与 `.title-panel .title`(白色文字及字号)。 + +### 第 3 页(assets/js/page3.js) + +通过 `glyphs` 数组放置任意背景字:`{ text, x, y, cls, vertical }`,坐标仍按格对齐。前景标题 `.credits` 由页面脚本追加,可在 `page3.css` 中调整字号与位置。 ## 实现说明 -- `scene.js` 先建立 28×44 网格并全部填「天」,再按数组覆盖元素;同 class 的连续字符合并为单个 `span` 输出。 +- `text-scene.js` 先建立 28×44 网格并全部填「天」,再按页面数据覆盖元素;同 class 的连续字符合并为单个 `span` 输出。 - 标题区把对应格子清空,并在同一位置叠放绝对定位的黑色面板;边缘与字符整格对齐,不会出现半个字。 - Zpix 单字步进为 13/12 em,因此 `.line` 使用 `letter-spacing: -0.083333em` 补偿,保证 28 列恰好落在 28em 宽内。 diff --git a/assets/css/common.css b/assets/css/common.css new file mode 100644 index 0000000..4a9d2b9 --- /dev/null +++ b/assets/css/common.css @@ -0,0 +1,69 @@ +/* 三页共用:字体、页面骨架、文字网格与标题框 */ + +@font-face { + font-family: "Zpix"; + src: url("../fonts/zpix.ttf") format("truetype"); + font-display: swap; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: #000; +} + +body { + min-height: 100dvh; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + font-family: "Zpix", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", + "Noto Sans SC", "Source Han Sans SC", sans-serif; + -webkit-text-size-adjust: 100%; +} + +/* 28 列 × 44 行文字网格,1em = 1 个汉字; + 字号统一在此控制,所有元素(含天线)字号一致 */ +.scene { + position: relative; + font-size: min(3.4vw, calc(100dvh / 44)); + line-height: 1em; + width: 28em; + height: 44em; + user-select: none; + font-kerning: none; +} + +.line { + display: block; + height: 1em; + line-height: 1em; + white-space: pre; + /* Zpix 单字步进为 13/12 em,补偿回 1em,保证 28 列正好落在 28em 内 */ + letter-spacing: -0.083333em; +} + +/* 标题框:按整格扣出的黑色矩形,位置与尺寸由页面数据决定 */ +.title-panel { + position: absolute; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + background: #000; +} + +.title-panel .title { + margin: 0; + color: #e6e6e6; + font-size: 1.8em; + font-weight: 400; + line-height: 1; + letter-spacing: 0; + white-space: nowrap; +} + +.title-panel .title-em { color: #fff; } diff --git a/assets/css/style.css b/assets/css/page1.css similarity index 53% rename from assets/css/style.css rename to assets/css/page1.css index bdf2d45..4082446 100644 --- a/assets/css/style.css +++ b/assets/css/page1.css @@ -1,70 +1,4 @@ -@font-face { - font-family: "Zpix"; - src: url("../fonts/zpix.ttf") format("truetype"); - font-display: swap; -} - -* { box-sizing: border-box; } - -html, body { - margin: 0; - padding: 0; - background: #000; -} - -body { - min-height: 100dvh; - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - font-family: "Zpix", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", - "Noto Sans SC", "Source Han Sans SC", sans-serif; - -webkit-text-size-adjust: 100%; -} - -/* 28 列 × 44 行文字网格,1em = 1 个汉字; - 字号在这里统一控制,所有元素(含天线)字号完全一致 */ -.scene { - position: relative; - font-size: min(3.4vw, calc(100dvh / 44)); - line-height: 1em; - width: 28em; - height: 44em; - user-select: none; - font-kerning: none; -} - -.line { - display: block; - height: 1em; - line-height: 1em; - white-space: pre; - /* Zpix 单字步进为 13/12 em,补偿回 1em,保证 28 列正好落在 28em 内 */ - letter-spacing: -0.083333em; -} - -/* —— 标题区:按整格扣出的黑色矩形,左右各留 3 格 —— */ -.title-panel { - position: absolute; - z-index: 1; - display: flex; - align-items: center; - justify-content: center; - background: #000; -} - -.title-panel .title { - margin: 0; - color: #e6e6e6; - font-size: 1.8em; - font-weight: 400; - line-height: 1; - letter-spacing: 0; - white-space: nowrap; -} - -.title-panel .title-em { color: #fff; } +/* 第 1 页:天空、云、落日、飞鸟、天线与楼群配色 */ /* —— 背景:空白格里的淡色「天」 —— */ .sky { color: rgba(190, 200, 240, 0.15); } diff --git a/assets/css/page3.css b/assets/css/page3.css new file mode 100644 index 0000000..551ab2c --- /dev/null +++ b/assets/css/page3.css @@ -0,0 +1,28 @@ +/* 第 3 页:无「天」背景;只保留电线、麻雀、枪、草与前景标题 */ + +.wire { color: #8794a5; } +.sparrow { color: #ff7a3f; } +.gun { color: #8b6f55; } +.grass { color: #4fc353; } +.hydrant { color: #a93b43; } +/* 火山岩 / 熔岩参考色,按暗度 70% 压暗(保留 30% 明度) */ +.rock { color: color-mix(in srgb, #d07a38 30%, #000); } +.lava-a { color: color-mix(in srgb, #ff8f3f 30%, #000); } +.lava-b { color: color-mix(in srgb, #ff5a2a 30%, #000); } + +/* 前景标题:不参与网格,字号与位置自由; + top 的 em 相对自身 1.6em 字号,3.125em = 距顶部 5 个背景格 */ +.credits { + position: absolute; + top: 3.125em; + left: 50%; + transform: translateX(-50%); + margin: 0; + color: #f2f2f2; + font-size: 1.6em; + font-weight: 400; + line-height: 1; + letter-spacing: 0; + white-space: nowrap; + z-index: 1; +} diff --git a/assets/js/page1.js b/assets/js/page1.js new file mode 100644 index 0000000..f775c1f --- /dev/null +++ b/assets/js/page1.js @@ -0,0 +1,87 @@ +/* 第 1 页场景数据 + 渲染引擎见 text-scene.js;坐标范围 x 0–27,y 0–43。 */ + +(() => { + /* 云:x/y 为最宽一行的左上角,三行 3/5/3;上下拉开分布 */ + const clouds = [ + { x: 10, y: 2, tone: 'pink' }, + { x: 18, y: 6, tone: 'violet' }, + { x: 1, y: 10, tone: 'orange' }, + { x: 23, y: 13, tone: 'lilac' }, + { x: 4, y: 16, tone: 'rose' }, + ]; + + /* 落日:整体下移贴近楼群,形成日落; + 色调按数组顺序,顶部偏红 → 底部偏黄 */ + 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 }, + { y: 22, x: 7, w: 14 }, + { y: 23, x: 7, w: 14 }, + { y: 24, x: 6, w: 16 }, + { y: 25, x: 6, w: 16 }, + { y: 26, x: 6, w: 16 }, + { y: 27, x: 6, w: 16 }, + { y: 28, x: 6, w: 16 }, + { y: 29, x: 7, w: 14 }, + { y: 30, x: 7, w: 14 }, + { y: 31, x: 8, w: 12 }, + { y: 32, x: 9, w: 10 }, + { y: 33, x: 10, w: 8 }, + { y: 34, x: 13, w: 2 }, + ]; + + /* 飞鸟:数量减少,间距不等,仅少量成对;上下拉开分布 */ + const birds = [ + { x: 25, y: 2, kind: 'magpie' }, + { x: 1, y: 4, kind: 'sparrow' }, + { x: 3, y: 8, kind: 'crow' }, + { x: 9, y: 13, kind: 'magpie' }, + { x: 9, y: 15, kind: 'sparrow' }, + { x: 26, y: 5, kind: 'crow' }, + { x: 17, y: 9, kind: 'sparrow' }, + { x: 21, y: 12, kind: 'magpie' }, + { x: 19, y: 15, kind: 'crow' }, + ]; + + /* 天线:单列「线」,从 y 向下叠 h 格;最右楼顶另加一根深色 */ + const antennas = [ + { x: 7, y: 28, h: 3 }, + { x: 22, y: 27, h: 4 }, + { x: 26, y: 34, h: 3, cls: 'ant ant-dark' }, + ]; + + /* 楼群:n 对应 .bld-n,最终按 x/y/w/h 铺满「楼」(高度裁到第 44 行) */ + const buildings = [ + { n: 1, x: 0, y: 35, w: 3, h: 9 }, + { n: 2, x: 3, y: 39, w: 3, h: 5 }, + { n: 3, x: 6, y: 31, w: 4, h: 13 }, + { n: 4, x: 10, y: 36, w: 3, h: 8 }, + { n: 5, x: 13, y: 30, w: 4, h: 14 }, + { n: 6, x: 17, y: 34, w: 3, h: 10 }, + { n: 7, x: 20, y: 31, w: 4, h: 13 }, + { n: 8, x: 24, y: 37, w: 4, h: 7 }, + ]; + + /* 标题区:整格扣出的居中矩形,左右各留 2 格 */ + const title = { + text: '关于「甲子」的虚构故事', + emphasis: '甲子', + top: 14, + height: 4, + side: 2, + }; + + TextScene.render({ + cols: 28, + rows: 44, + clouds, + sun, + birds, + antennas, + buildings, + title, + }); +})(); diff --git a/assets/js/page3.js b/assets/js/page3.js new file mode 100644 index 0000000..d8b7f09 --- /dev/null +++ b/assets/js/page3.js @@ -0,0 +1,134 @@ +/* 第 3 页场景数据 + 前景:演职员表;背景:电线、麻雀、枪、草。 + 本页不铺「天」,空白格保持为空。 */ + +(() => { + /* 固定种子的噪声,保证每次渲染一致 */ + const noise = (x, y) => { + let h = Math.imul(x, 374761393) + Math.imul(y, 668265263); + h = Math.imul(h ^ (h >>> 13), 1274126177); + return ((h ^ (h >>> 16)) >>> 0) / 4294967296; + }; + + /* 火山:占据下半屏,下 2/3 为山体、上 1/3 为熔岩棋盘格 */ + const volcano = []; + const VOLCANO_TOP = 21; + const VOLCANO_BOTTOM = 42; + + /* 每列的熔岩下沿,形成不规则的熔岩流 */ + const lavaBottom = Array.from({ length: 28 }, (_, x) => { + return VOLCANO_TOP + 6 + Math.floor(noise(x, 3) * 3); + }); + [5, 13, 21].forEach((x) => { + lavaBottom[x] += 4 + Math.floor(noise(x, 9) * 3); + }); + + /* 两层熔岩的分界:按列小幅随机起伏 */ + const lavaSplit = Array.from({ length: 28 }, (_, x) => { + const offset = Math.round((noise(x, 13) - 0.5) * 4); + return Math.max( + VOLCANO_TOP + 2, + Math.min(VOLCANO_TOP + 5, VOLCANO_TOP + 3 + offset) + ); + }); + + /* 主熔岩区以下为山体,两侧做对称平滑处理 */ + const ROCK_START = VOLCANO_TOP + 7; + + for (let y = VOLCANO_TOP; y <= VOLCANO_BOTTOM; y++) { + /* 距底部 5 格起铺满整幅宽度,避免山体过陡 */ + const fullWidthAt = VOLCANO_BOTTOM - 5; + const t = (y - VOLCANO_TOP) / (fullWidthAt - VOLCANO_TOP); + const baseWidth = + y >= fullWidthAt ? 28 : Math.round(10 + 18 * Math.min(1, Math.max(0, t))); + + let width; + let left; + + if (y < ROCK_START) { + /* 熔岩区保持原有随机形状 */ + const jitter = + y >= fullWidthAt ? 0 : Math.round((noise(0, y) - 0.5) * 2); + width = Math.max(4, Math.min(28, baseWidth + jitter)); + left = Math.floor((28 - width) / 2); + left += Math.round((noise(1, y) - 0.5) * 2); + left = Math.max(0, Math.min(28 - width, left)); + if (y === VOLCANO_TOP + 1) left = Math.max(0, left - 1); + } else { + /* 山体左右对称,宽度取偶数和,保留低频小幅随机 */ + const wobble = Math.round((noise(7, Math.floor(y / 3)) - 0.5) * 2); + let w = y >= fullWidthAt ? 28 : Math.min(28, baseWidth + wobble); + w -= w % 2; + width = Math.max(4, w); + left = (28 - width) / 2; + } + + /* 顶部火山口:仅两行凹下,第一行缺口更宽 */ + const craterGap = y < ROCK_START ? [4, 2][y - VOLCANO_TOP] || 0 : 0; + const gapStart = craterGap ? Math.floor((width - craterGap) / 2) : 0; + + for (let i = 0; i < width; i++) { + if (craterGap && i >= gapStart && i < gapStart + craterGap) continue; + + const x = left + i; + const isLava = y <= lavaBottom[x]; + const isUpperLava = isLava && y <= lavaSplit[x]; + + volcano.push({ + text: isLava ? ((x + y) % 2 === 0 ? '熔' : '岩') : '石', + x, + y, + cls: isLava ? (isUpperLava ? 'lava-a' : 'lava-b') : 'rock', + }); + } + } + + /* 第十行左侧补一个「熔」 */ + const row10 = volcano.filter((cell) => cell.y === VOLCANO_TOP + 9); + const row10Left = Math.min(...row10.map((cell) => cell.x)); + volcano.push({ + text: '熔', + x: row10Left - 1, + y: VOLCANO_TOP + 9, + cls: 'lava-b', + }); + + TextScene.render({ + cols: 28, + rows: 44, + skyChar: '', + glyphs: [ + /* 电线:整行贯穿页面 */ + { text: '电线'.repeat(14), x: 0, y: 8, cls: 'wire' }, + /* 麻雀:竖排于从左到右第四格,落在电线上方 */ + { text: '麻雀', x: 3, y: 6, cls: 'sparrow', vertical: true }, + /* 火山铺在底部背景,枪、草、消防栓在其上层 */ + ...volcano, + /* 枪:多个「枪」拼出枪形,占最右 4 列,枪口指向左上方的麻雀 */ + { text: '枪', x: 24, y: 20, cls: 'gun' }, + { 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' }, + /* 消防栓:最后三行分散竖排九根,左侧留 2 格 */ + ...[2, 5, 8, 10, 13, 16, 19, 23, 25].map((x) => ({ + text: '消防栓', + x, + y: 41, + cls: 'hydrant', + vertical: true, + })), + ], + }); + + const scene = document.querySelector('.scene'); + const credits = document.createElement('h1'); + credits.className = 'credits'; + credits.textContent = '演职员表'; + scene.append(credits); +})(); diff --git a/assets/js/scene.js b/assets/js/scene.js deleted file mode 100644 index 6359fdb..0000000 --- a/assets/js/scene.js +++ /dev/null @@ -1,170 +0,0 @@ -/* 落日文字画 · 28 × 44 网格 - 构图全部由下方数组描述;空白格自动用「天」铺满。 - 修改元素只需调整数组中的坐标与尺寸,行列范围:x 0–27,y 0–43。 */ - -(() => { - const COLS = 28; - const ROWS = 44; - - /* 云:x/y 为最宽一行的左上角,三行 3/5/3;上下拉开分布 */ - const clouds = [ - { x: 10, y: 2, tone: 'pink' }, - { x: 18, y: 6, tone: 'violet' }, - { x: 1, y: 10, tone: 'orange' }, - { x: 23, y: 13, tone: 'lilac' }, - { x: 4, y: 16, tone: 'rose' }, - ]; - - /* 落日:整体下移贴近楼群,形成日落; - 色调按数组顺序,顶部偏红 → 底部偏黄 */ - 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 }, - { y: 22, x: 7, w: 14 }, - { y: 23, x: 7, w: 14 }, - { y: 24, x: 6, w: 16 }, - { y: 25, x: 6, w: 16 }, - { y: 26, x: 6, w: 16 }, - { y: 27, x: 6, w: 16 }, - { y: 28, x: 6, w: 16 }, - { y: 29, x: 7, w: 14 }, - { y: 30, x: 7, w: 14 }, - { y: 31, x: 8, w: 12 }, - { y: 32, x: 9, w: 10 }, - { y: 33, x: 10, w: 8 }, - { y: 34, x: 13, w: 2 }, - ]; - - /* 飞鸟:数量减少,间距不等,仅少量成对;上下拉开分布 */ - const birds = [ - { x: 25, y: 2, kind: 'magpie' }, - { x: 1, y: 4, kind: 'sparrow' }, - { x: 3, y: 8, kind: 'crow' }, - { x: 9, y: 13, kind: 'magpie' }, - { x: 9, y: 15, kind: 'sparrow' }, - { x: 26, y: 5, kind: 'crow' }, - { x: 17, y: 9, kind: 'sparrow' }, - { x: 21, y: 12, kind: 'magpie' }, - { x: 19, y: 15, kind: 'crow' }, - ]; - - /* 天线:单列「线」,从 y 向下叠 h 格;最右楼顶另加一根深色 */ - const antennas = [ - { x: 7, y: 28, h: 3 }, - { x: 22, y: 27, h: 4 }, - { x: 26, y: 34, h: 3, cls: 'ant ant-dark' }, - ]; - - /* 楼群:n 对应 .bld-n,最终按 x/y/w/h 铺满「楼」(高度裁到第 44 行) */ - const buildings = [ - { n: 1, x: 0, y: 35, w: 3, h: 9 }, - { n: 2, x: 3, y: 39, w: 3, h: 5 }, - { n: 3, x: 6, y: 31, w: 4, h: 13 }, - { n: 4, x: 10, y: 36, w: 3, h: 8 }, - { n: 5, x: 13, y: 30, w: 4, h: 14 }, - { n: 6, x: 17, y: 34, w: 3, h: 10 }, - { n: 7, x: 20, y: 31, w: 4, h: 13 }, - { n: 8, x: 24, y: 37, w: 4, h: 7 }, - ]; - - /* 标题区:整格扣出的居中矩形,左右各留 2 格 */ - const TITLE = { - text: '关于「甲子」的虚构故事', - emphasis: '甲子', - top: 14, - height: 4, - side: 2, - }; - - const BIRD_WORDS = { sparrow: '麻雀', magpie: '喜鹊', crow: '乌鸦' }; - - const grid = Array.from({ length: ROWS }, () => - Array.from({ length: COLS }, () => ({ ch: '天', cls: 'sky' })) - ); - - const put = (x, y, ch, cls) => { - if (x < 0 || x >= COLS || y < 0 || y >= ROWS) { - throw new Error(`元素超出网格范围:(${x}, ${y})`); - } - grid[y][x] = { ch, cls }; - }; - - const putRow = (y, x, n, ch, cls) => { - for (let i = 0; i < n; i++) put(x + i, y, ch, cls); - }; - - for (const { x, y, tone } of clouds) { - const cls = `cloud cloud-${tone}`; - putRow(y, x + 1, 3, '云', cls); - putRow(y + 1, x, 5, '云', cls); - putRow(y + 2, x + 1, 3, '云', cls); - } - - sun.forEach(({ y, x, w }, i) => { - const tone = `sun-t${i + 1}`; - for (let dx = 0; dx < w; dx++) put(x + dx, y, '日', `sun ${tone}`); - }); - - for (const { x, y, kind } of birds) { - const [a, b] = BIRD_WORDS[kind]; - put(x, y, a, `bird-${kind}`); - put(x + 1, y, b, `bird-${kind}`); - } - - for (const { x, y, h, cls = 'ant' } of antennas) { - for (let i = 0; i < h; i++) put(x, y + i, '线', cls); - } - - for (const { n, x, y, w, h } of buildings) { - for (let j = 0; j < h; j++) putRow(y + j, x, w, '楼', `bld bld-${n}`); - } - - for (let y = TITLE.top; y < TITLE.top + TITLE.height; y++) { - for (let x = TITLE.side; x < COLS - TITLE.side; x++) { - grid[y][x] = { ch: ' ', cls: 'cut' }; - } - } - - /* 把同 class 的连续字符合并成一个 span,减少 DOM 节点 */ - const html = grid - .map((row) => { - let line = ''; - for (let i = 0; i < COLS; ) { - let j = i + 1; - while (j < COLS && row[j].cls === row[i].cls) j++; - const text = row.slice(i, j).map((cell) => cell.ch).join(''); - line += `${text}`; - i = j; - } - return `
${line}
`; - }) - .join(''); - - const scene = document.querySelector('.scene'); - scene.innerHTML = html; - - const panel = document.createElement('div'); - panel.className = 'title-panel'; - panel.style.top = `${TITLE.top}em`; - panel.style.left = `${TITLE.side}em`; - panel.style.width = `${COLS - TITLE.side * 2}em`; - panel.style.height = `${TITLE.height}em`; - - const title = document.createElement('h1'); - title.className = 'title'; - - const [before, after] = TITLE.text.split(TITLE.emphasis); - if (after === undefined) { - title.textContent = TITLE.text; - } else { - const em = document.createElement('span'); - em.className = 'title-em'; - em.textContent = TITLE.emphasis; - title.append(before, em, after); - } - - panel.append(title); - scene.append(panel); -})(); diff --git a/assets/js/text-scene.js b/assets/js/text-scene.js new file mode 100644 index 0000000..9c87575 --- /dev/null +++ b/assets/js/text-scene.js @@ -0,0 +1,137 @@ +/* 文字画共享渲染引擎 + 页面脚本调用 TextScene.render({...}) 提供数据即可,示例见 page1.js。 + 网格坐标:x 0–cols-1,y 0–rows-1;空白格自动填充天空字符。 */ + +(() => { + const BIRD_WORDS = { + sparrow: '麻雀', + magpie: '喜鹊', + crow: '乌鸦', + }; + + function render({ + cols, + rows, + skyChar = '天', + skyClass = 'sky', + clouds = [], + sun = [], + birds = [], + antennas = [], + buildings = [], + glyphs = [], + title, + container = '.scene', + }) { + if (!cols || !rows) throw new Error('TextScene.render 需要 cols 与 rows'); + + // skyChar 传空字符串时不铺背景字,空格仍占满整格 + const blank = skyChar + ? { ch: skyChar, cls: skyClass } + : { ch: ' ', cls: 'void' }; + + const grid = Array.from({ length: rows }, () => + Array.from({ length: cols }, () => ({ ...blank })) + ); + + const put = (x, y, ch, cls) => { + if (x < 0 || x >= cols || y < 0 || y >= rows) { + throw new Error(`元素超出网格范围:(${x}, ${y})`); + } + grid[y][x] = { ch, cls }; + }; + + const putRow = (y, x, n, ch, cls) => { + for (let i = 0; i < n; i++) put(x + i, y, ch, cls); + }; + + for (const { x, y, tone } of clouds) { + const cls = `cloud cloud-${tone}`; + putRow(y, x + 1, 3, '云', cls); + putRow(y + 1, x, 5, '云', cls); + putRow(y + 2, x + 1, 3, '云', cls); + } + + sun.forEach(({ y, x, w }, i) => { + const tone = `sun-t${i + 1}`; + for (let dx = 0; dx < w; dx++) put(x + dx, y, '日', `sun ${tone}`); + }); + + for (const { x, y, kind } of birds) { + const word = BIRD_WORDS[kind]; + if (!word) throw new Error(`未知鸟种:${kind}`); + const [a, b] = [...word]; + put(x, y, a, `bird-${kind}`); + put(x + 1, y, b, `bird-${kind}`); + } + + for (const { x, y, h, cls = 'ant' } of antennas) { + for (let i = 0; i < h; i++) put(x, y + i, '线', cls); + } + + for (const { n, x, y, w, h } of buildings) { + for (let j = 0; j < h; j++) putRow(y + j, x, w, '楼', `bld bld-${n}`); + } + + for (const { text, x, y, cls = 'glyph', vertical = false } of glyphs) { + [...text].forEach((ch, i) => { + put(vertical ? x : x + i, vertical ? y + i : y, ch, cls); + }); + } + + if (title) { + for (let y = title.top; y < title.top + title.height; y++) { + for (let x = title.side; x < cols - title.side; x++) { + grid[y][x] = { ch: ' ', cls: 'cut' }; + } + } + } + + /* 把同 class 的连续字符合并成一个 span,减少 DOM 节点 */ + const html = grid + .map((row) => { + let line = ''; + for (let i = 0; i < cols; ) { + let j = i + 1; + while (j < cols && row[j].cls === row[i].cls) j++; + const text = row.slice(i, j).map((cell) => cell.ch).join(''); + line += `${text}`; + i = j; + } + return `
${line}
`; + }) + .join(''); + + const scene = document.querySelector(container); + if (!scene) throw new Error(`未找到容器:${container}`); + scene.innerHTML = html; + + if (!title) return; + + const panel = document.createElement('div'); + panel.className = 'title-panel'; + panel.style.top = `${title.top}em`; + panel.style.left = `${title.side}em`; + panel.style.width = `${cols - title.side * 2}em`; + panel.style.height = `${title.height}em`; + + const titleEl = document.createElement('h1'); + titleEl.className = 'title'; + + const { text, emphasis } = title; + if (emphasis && text.includes(emphasis)) { + const [before, after] = text.split(emphasis); + const em = document.createElement('span'); + em.className = 'title-em'; + em.textContent = emphasis; + titleEl.append(before, em, after); + } else { + titleEl.textContent = text; + } + + panel.append(titleEl); + scene.append(panel); + } + + window.TextScene = { render }; +})(); diff --git a/index.html b/page1.html similarity index 58% rename from index.html rename to page1.html index 6bccb85..17e5b67 100644 --- a/index.html +++ b/page1.html @@ -5,8 +5,10 @@ 封面 - - + + + + diff --git a/page3.html b/page3.html new file mode 100644 index 0000000..813525b --- /dev/null +++ b/page3.html @@ -0,0 +1,18 @@ + + + + + + +演职员表 + + + + + + + +
+ + + diff --git a/scripts/export_pdf.py b/scripts/export_pdf.py index a198bb8..ca05a27 100644 --- a/scripts/export_pdf.py +++ b/scripts/export_pdf.py @@ -1,10 +1,11 @@ -"""将 index.html 导出为 PDF,画面四边各留恰好 1em。 +"""将文字画页面导出为 PDF,画面四边各留恰好 1em。 页面为 28 × 44 格文字画,1em 即一个汉字格。导出页尺寸为 30em × 46em, 场景四周各留 1em;底色铺满整页。 用法: uv run python scripts/export_pdf.py + 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 """ @@ -16,7 +17,6 @@ import pathlib from playwright.sync_api import sync_playwright ROOT = pathlib.Path(__file__).resolve().parents[1] -HTML = ROOT / "index.html" COLS = 28 ROWS = 44 @@ -71,7 +71,7 @@ def launch_browser(pw): ) -def export(em: float, out: pathlib.Path) -> None: +def export(em: float, out: pathlib.Path, html: pathlib.Path) -> None: css, page_w, page_h = export_css(em) with sync_playwright() as pw: @@ -80,7 +80,7 @@ def export(em: float, out: pathlib.Path) -> None: viewport={"width": round(page_w), "height": round(page_h)}, device_scale_factor=1, ) - page.goto(HTML.as_uri(), wait_until="load") + 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 字体未加载,无法导出。") @@ -128,6 +128,11 @@ def export(em: float, out: pathlib.Path) -> None: def main() -> None: parser = argparse.ArgumentParser(description="将文字画页面导出为 PDF(四周各留 1em)") + parser.add_argument( + "--page", + default="page1.html", + help="要导出的 HTML 文件,默认 page1.html", + ) parser.add_argument( "--em", type=float, @@ -144,7 +149,10 @@ def main() -> None: args = parser.parse_args() if args.em <= 0: parser.error("--em 必须大于 0") - export(args.em, args.out.resolve()) + html = (ROOT / args.page).resolve() + if not html.is_file(): + parser.error(f"页面不存在:{args.page}") + export(args.em, args.out.resolve(), html) if __name__ == "__main__":