
在现代网页与移动端设计中横排Horizontal Layout早已经成为了绝对的主流。然而当我们翻开泛黄的线装古籍、宋版木刻诗集或是欣赏一张茶道雅集的手写便签时那种**自上而下纵向书写、自右向左逐列铺展Vertical-RL**的古典汉字版面总能瞬间唤醒一种跨越千年的凝练与沉静之美。在 CSS 中现代浏览器提供了writing-mode: vertical-rl规范但在手账应用中当我们需要将日记、短诗通过 HTML5 Canvas 导出为印刷级高分辨率300 DPI的宣纸明信片、压花书签或线装书封面时排版就必须彻底脱离 DOM 树由我们在离屏画布的直角坐标系中逐字计算。很多初学者在用 Canvas 实现竖排时往往只是写一个简单的循环y fontSize。这种机械做法在中文古籍排版中会暴露出三大致命的丑陋缺陷标点符号位置严重违和在竖排中逗号、句号绝不能呆在汉字的正下方而应当靠在字符边界的右上角遇到竖排专用的书名号、破折号时更需要旋转 90 度英文与数字横卧倒伏缺少“纵中横”机制当诗句中出现年份如“2026”或两位数时字符横着倒在版面里极其刺眼违反了日汉排版中经典的“纵中横Tate-chu-yoko”原则缺少古籍栏线与版心Fish-tail Column Rules的呼应真正的善本线装书列与列之间都有淡朱砂或古墨细线相隔版心正中央常带有古雅的“鱼尾”折叠标记。为了在“秋日手账杂货铺”中复刻这种原汁原味的活字印刷美学听汐设计了一套纯前端 Canvas 竖排古籍排版算法引擎。本文将拆解其背后的几何坐标映射与纵中横排版法则。一、古典汉字竖排的三大核心排版规范在宋代活字与明清刻本排版传统中纵向文字排布有着极为严苛的审美约定自右向左列推进Right-to-Left Column Flow起始列位于画布的最右侧填满一整列的高度后向左平移一个“列间距Column Gap”开启新一列的书写竖排标点符号偏置Vertical Punctuation Offsets句号。与逗号应当偏移至汉字右上角约 $1/4$ 字符方块处破折号——与省略号……必须在正中央贯穿整列纵中横排版机制Tate-chu-yoko当在竖排文本中遇到 1 到 2 位的阿拉伯数字或短字母如10、AI时不能一个字占一行而是应当将其横向并列缩放塞进单个汉字的纵向方框之中保持整列字符行进节奏的平稳。二、坐标映射体系从一维字符串到二维竖向网格算法的坐标换算逻辑如下设画布有效宽度为 $W$单列最大可用高度为 $H_{\max}$字符字号为 $S$字间距为 $L_{\text{gap}}$列间距为 $C_{\text{gap}}$。当前列索引为 $c$从 $0$ 开始递增当前列的基准水平 X 坐标为$$X_c W - \text{marginRight} - c \times (S C_{\text{gap}})$$列内第 $r$ 个字符的垂直 Y 坐标为$$Y_r \text{marginTop} r \times (S L_{\text{gap}})$$当 $Y_r S \text{marginTop} H_{\max}$ 时当前列自动闭合列索引 $c \leftarrow c 1$垂直游标 $Y$ 复位至顶部。三、纯前端古籍排版引擎核心工程代码下面是封装完备的 TypeScript 模块直接在原生 HTML5 Canvas 2D 上进行精微活字重现// types.ts export interface AncientTypesetOptions { fontSize: number; fontFamily: string; charSpacing: number; // 字间距默认 6px columnSpacing: number; // 列间距默认 20px inkColor?: string; // 墨水颜色古墨深灰咖 #2B231D ruleColor?: string; // 栏线颜色朱砂红 #B83B26 或淡墨灰 showColumnRules?: boolean; // 是否绘制古籍竖向栏线 } // AncientVerticalTypesetter.ts export class AncientVerticalTypesetter { // 位于右上角偏置的标点集合 private static cornerPunctuation new Set([, 。, 、, , , ,, .]); /** * 将长文本排版为纯正的古籍竖排 Canvas 画布 */ public static renderAncientPage( text: string, width 720, height 960, options: PartialAncientTypesetOptions {} ): HTMLCanvasElement { const { fontSize 24, fontFamily Songti SC, Source Han Serif SC, serif, charSpacing 8, columnSpacing 28, inkColor #241C18, ruleColor #B83B26, showColumnRules true } options; const canvas document.createElement(canvas); canvas.width width; canvas.height height; const ctx canvas.getContext(2d)!; // 1. 底板铺设温润的仿古手工宣纸质感底色 ctx.fillStyle #F5EFE0; ctx.fillRect(0, 0, width, height); // 2. 绘制古籍双回纹朱砂外边框版心外框 const borderMargin 40; ctx.strokeStyle ruleColor; ctx.lineWidth 2.5; ctx.strokeRect(borderMargin, borderMargin, width - borderMargin * 2, height - borderMargin * 2); // 内细边线 ctx.lineWidth 0.8; ctx.strokeRect(borderMargin 6, borderMargin 6, width - borderMargin * 2 - 12, height - borderMargin * 2 - 12); // 3. 计算版心内部可用区域 const contentRight width - borderMargin - 32; const contentTop borderMargin 32; const contentBottom height - borderMargin - 32; const maxColumnHeight contentBottom - contentTop; ctx.font ${fontSize}px ${fontFamily}; ctx.textBaseline top; let currentX contentRight; let currentY contentTop; let columnIndex 0; // 预分词将连续的两位阿拉伯数字或简短英文单词提取为纵中横单元 const tokens this.tokenizeForVertical(text); // 记录所有列的 X 坐标用于后续绘制平行的竖向古籍分隔线 const columnXPositions: number[] [currentX]; for (const token of tokens) { // 换行符显式换列 if (token \n) { currentX - (fontSize columnSpacing); currentY contentTop; columnIndex; columnXPositions.push(currentX); continue; } // 检查当前列高度是否触顶溢出 const tokenHeight token.length 1 ? (fontSize charSpacing) : (fontSize charSpacing); if (currentY tokenHeight contentBottom) { currentX - (fontSize columnSpacing); currentY contentTop; columnIndex; columnXPositions.push(currentX); } // 如果超出左侧版心停止排版 if (currentX borderMargin 24) { console.warn(文本超出古籍版心范围已截断); break; } // 绘制单个 Token if (token.length 1) { this.renderSingleChar(ctx, token, currentX, currentY, fontSize, inkColor); currentY fontSize charSpacing; } else { // 纵中横排版2位数字/短词 this.renderTateChuYoko(ctx, token, currentX, currentY, fontSize, inkColor); currentY fontSize charSpacing; } } // 4. 绘制竖向隔栏线古典朱砂界栏 if (showColumnRules) { ctx.save(); ctx.strokeStyle ruleColor; ctx.lineWidth 0.7; ctx.globalAlpha 0.65; for (const colX of columnXPositions) { const lineX colX - columnSpacing / 2; if (lineX borderMargin 12 lineX width - borderMargin - 12) { ctx.beginPath(); ctx.moveTo(lineX, contentTop - 12); ctx.lineTo(lineX, contentBottom 12); ctx.stroke(); } } ctx.restore(); } // 5. 底部右侧绘制听汐小印章 this.renderChop(ctx, contentRight - 20, contentBottom - 50, 42, ruleColor); return canvas; } private static renderSingleChar( ctx: CanvasRenderingContext2D, ch: string, x: number, y: number, fontSize: number, color: string ): void { ctx.save(); ctx.fillStyle color; // 标点偏置矫正 if (this.cornerPunctuation.has(ch)) { // 将标点平移到字符方块的右上角 ctx.font ${fontSize * 0.9}px sans-serif; ctx.fillText(ch, x fontSize * 0.45, y - fontSize * 0.2); } else { ctx.fillText(ch, x, y); } ctx.restore(); } /** * 纵中横Tate-chu-yoko将 2 个半角字符横排在一个汉字方块内 */ private static renderTateChuYoko( ctx: CanvasRenderingContext2D, token: string, x: number, y: number, fontSize: number, color: string ): void { ctx.save(); ctx.fillStyle color; // 缩小字号为 60%横排居中 ctx.font bold ${fontSize * 0.65}px sans-serif; ctx.textAlign center; ctx.fillText(token, x fontSize / 2, y fontSize * 0.2); ctx.restore(); } /** * 分词器将数字与中文安全拆分 */ private static tokenizeForVertical(text: string): string[] { const tokens: string[] []; let i 0; while (i text.length) { const ch text[i]; // 提取连续 1~2 位数字作为纵中横单元 if (/\d/.test(ch)) { if (i 1 text.length /\d/.test(text[i 1])) { tokens.push(text.slice(i, i 2)); i 2; continue; } } tokens.push(ch); i; } return tokens; } /** * 绘制朱砂手写印章 */ private static renderChop( ctx: CanvasRenderingContext2D, x: number, y: number, size: number, color: string ): void { ctx.save(); ctx.strokeStyle color; ctx.lineWidth 2; ctx.strokeRect(x, y, size, size); ctx.fillStyle color; ctx.font bold ${size * 0.42}px Songti SC, serif; ctx.textAlign center; ctx.textBaseline middle; ctx.fillText(听汐, x size / 2, y size / 2); ctx.restore(); } }四、排版效果对比与手账相框实操在排版一段经典的秋日小品文如“深秋十日晴空万里林下落叶厚积煮茶听风甚适。”时对比效果极其明显传统简单纵向堆叠逗号掉在字符正中间像个小点数字“10”竖向拆成“1”和“0”两行版面散乱无章应用古籍线装算法后正文如古铜活字般方正自持标点温润地悬于右上角两位数字紧凑并排朱砂色细细的纵向界栏将一列列文字规整约束在版心之中右下角一枚朱红印章如画龙点睛。导出的图片无论是打印成藏书票、还是作为手账封底都充盈着如宋版书般百读不厌的书卷文雅。五、结语在横排屏幕铺天盖地的数字化时代重新拾起竖排活字的经典秩序并不只是复古怀旧而是在快节奏的信息爆炸中寻找一种能够让目光慢下来、让心境沉下来的仪式感。用严谨的数学坐标计算每一个汉字的驻留之处用现代 Canvas 算法守护传统活字印刷的微小尊严。在周日的书桌前用一段竖排古墨为秋天的生活写下一首端庄从容的长诗。