我们是如何做"结构化自定义模板"的:在不开放任意 CSS 的前提下让用户改样式
很多用户反馈过同一个需求:“GitHub 模板挺好看的,但能不能把标题色改成我们公司的品牌色?行高再宽一点?”
听起来很小的功能,但真要做,背后有一个绕不开的取舍:要不要开放任意 CSS?
这篇文章记录我们最终选择的方案——基于现有系统模板做结构化覆盖——以及为什么这样做更适合 md-to.com 这种”一份内容多格式导出”的场景。
两种常见做法的两个极端
市面上 Markdown 工具处理”自定义样式”基本是两种思路:
第一种:只给固定模板。 简单、稳定,但不够用。用户挑了一个最接近的模板之后,往往只差”标题想换个颜色”这一步。
第二种:开放任意 CSS。 自由度拉满,预览区想怎么改就怎么改。问题是:浏览器里好看不代表导出后好看。
我们做的是 Markdown 转 PDF / Word / HTML / 图片的工具,导出链路里有几条是不消费 CSS 的:
- Word(
.docx)走的是 OOXML 样式,根本没有”任意 CSS”这个概念。 - PDF 打印有自己的分页、页边距规则,纯靠浏览器 CSS 调出来的版式很容易在打印时翻车。
- 图片导出走 canvas 截图,对某些 CSS 特性(比如部分
filter、backdrop-filter)支持有限。
如果让用户写任意 CSS,预览区一切正常,导出之后样式各种走样——这种”看到的不是拿到的”是非常糟糕的用户体验。
所以我们走第三条路。
我们的方案:基准模板 + 结构化覆盖
核心思路一句话:用户先选一个系统模板,再在它上面改一组结构化的字段。
存的不是完整模板,而是”基准模板 ID + 覆盖项”:
{
version: 1,
baseTemplateId: 'classic',
overrides: {
colors: {
heading: '#0F766E'
},
fonts: {
body: 'Noto Sans SC'
},
fontSizes: {
body: 13
},
spacing: {
lineHeight: 1.7
}
}
}
为什么不直接存完整模板?三个原因:
- 基准升级时用户能跟着受益。 比如我们后续优化了 Classic 模板的代码块边距,用户的自定义模板会自动用上新值,因为 overrides 里没覆盖这一项。
- payload 小,前后端校验都简单。 后端只需要校验白名单里的几个字段,而不是一份完整的模板对象。
- “基于哪个模板做的修改”这个语义被显式保留下来。 UI 上可以告诉用户”你正在 Classic 的基础上做了 3 处修改”。
下面是当前的编辑器自定义样式界面,覆盖了颜色、字体、字号、间距四组高频字段:

运行链路:从一次点击到一份导出
整个系统串起来看是三条链路。
链路一:实时预览
用户在编辑器里改一个颜色,预览区要立刻变。流程是:
- 编辑器把新的覆盖值写入
localStorage.customDocumentTemplate。 - 编辑器派发
template-change事件。 MarkdownPreview监听到事件,调用getActiveTemplate()。getActiveTemplate()读出documentTemplate(当前基准)和customDocumentTemplate(覆盖项),如果两者匹配就调用createCustomTemplate(baseTemplate, overrides)合并出一份完整模板。generatePreviewCSS(template)重新生成预览样式。
合并的逻辑很直接,就是字段层面的浅合并:
return {
...baseTemplate,
id: `custom:${baseTemplate.id}`,
colors: {
...baseTemplate.colors,
...(overrides.colors ?? {}),
},
fonts: {
...baseTemplate.fonts,
...(overrides.fonts ?? {}),
},
fontSizes: {
...baseTemplate.fontSizes,
...(overrides.fontSizes ?? {}),
},
spacing: {
...baseTemplate.spacing,
...(overrides.spacing ?? {}),
},
};
链路二:保存为私有模板
用户点”保存模板”时,工具 adapter 会把当前状态打包:
{
version: 1,
toolSlug,
documentTemplateId,
customDocumentTemplate
}
这份 payload 写到 saved_templates.settings_json。注意 documentTemplateId 和 customDocumentTemplate 是分开存的——一份私有模板的语义是”基于 X 模板的 Y 个修改”,而不是一份脱离来源的样式快照。
链路三:应用一份私有模板
用户从 PrivateTemplateSelector 点回来一份模板,或者通过 ?tpl=<id> 参数加载:
- adapter 调用
applySettings(settingsJson)。 - 如果 payload 里有
customDocumentTemplate,写回 localStorage;没有则清掉。 - 应用
documentTemplateId。 - 派发
template-change,预览 / 导出链路都拿到合并后的完整模板。
一个容易忽略但很关键的细节:选系统模板时清掉自定义覆盖
我们在 setActiveTemplate(templateId) 里加了一行:
localStorage.removeItem('customDocumentTemplate');
为什么?因为如果不清,会出现这种诡异情况:
- 用户在 Classic 模板上改了标题色为绿色,存到了 localStorage。
- 用户切换到 Dracula 模板。
- 切回 Classic,标题又是绿色——但用户根本不记得自己改过。
我们把”选择系统模板”定义成一个明确的重置动作:你选了一个干净的模板,那就是干净的。要在它上面改,重新点”自定义”。这个语义比”尽可能保留用户上次的修改”要清楚得多。
TemplatePickerModal 里点”应用”时也会执行同样的清理。
后端校验:白名单是底线
后端 validateTemplateInput() 对 settingsJson.customDocumentTemplate 做了严格校验:
customDocumentTemplate可选;存在时必须是对象。version必须为1(留迁移空间)。baseTemplateId必须是非空字符串。overrides必须是对象。- 未列入白名单的字段会被拒绝。
白名单覆盖了现在 UI 里能改的所有字段,比 UI 略大一点,给以后补字段留余地:
| 类别 | 允许字段 |
|---|---|
colors | heading, text, link, codeBackground, codeBorder, codeText, quoteText, quoteBorder, quoteBackground, tableHeaderBg, tableHeaderText, tableBorder, tableRowOdd, tableRowEven |
fonts | heading, body, code |
fontSizes | h1, h2, h3, h4, h5, h6, body, code |
spacing | headingBefore, headingAfter, paragraphBefore, paragraphAfter, lineHeight |
白名单是这套设计的安全底线:因为我们不接受任意键,所以以后给某个字段加渲染逻辑或者改名字,就算线上有用户存了旧字段,它也不会混进运行时模板里污染输出。
收益:导出链路完全不用关心”自定义模板”
合并之后是一份完整的 DocumentTemplate,所以下游所有导出函数都不需要改:
- 预览:
generatePreviewCSS(template) - PDF / 打印:
generatePdfStyles()/generatePrintStyles() - HTML:
generateExportHTML() - 富文本复制:
generateRichTextHTML() - Word:
templateToDocxStyles(template)
这恰好就是我们一开始拒绝”任意 CSS”的最大回报:只要所有输出继续消费 DocumentTemplate 这个统一中间表示,跨格式一致性几乎是免费的。用户改的标题颜色,在浏览器预览、PDF、Word、长图里看到的都是同一个颜色——不是”看起来差不多”,是字面意义上的同一个 hex 值,因为最终都是从同一份合并后的模板里读出来的。
当前限制
第一版我们是有意做窄的:
- 只开放高频字段,没有把所有颜色、字号、间距都暴露出来。
- 没有自定义模板的命名入口;命名仍然走”保存模板”弹窗。
- 不支持模板 JSON 导入 / 导出。
- 不开放任意 CSS。
- 编辑器 UI 是轻量实现,分组面板、预设色板、移动端布局都还有优化空间。
后续方向
这套结构往前走有几个明显的路标:
- 更多可编辑字段。 表格边框色、奇偶行背景、引用背景、代码文字色——白名单里其实已经留好了位置,缺的是 UI。
- 统一的字段 schema。 让前端表单和后端校验共用同一份定义,避免两边漂移。
- workspace 模板列表加”编辑”入口。 现在改完只能新存一份,不能在原模板上继续改。
- “复制系统模板为私有模板”的显式流程。 让”我想基于 Classic 改一份自己的”变成一个更直接的动作。
- 导入 / 导出自定义模板 JSON。 团队内共享样式时会很有用。
总结
回头看,这套方案的关键不是”我们做了什么功能”,而是”我们没做什么”:
- 没做任意 CSS——避免了跨格式翻车。
- 没存完整模板快照——保留了基准模板升级的红利。
- 没在切换系统模板时偷偷保留覆盖——避免了”我没改但样式变了”的迷惑。
- 后端没接受白名单外的字段——给将来留了演进空间。
约束反而带来了更可预测的体验。在一个跨多种导出格式的工具里,这种可预测性比自由度更值钱。
如果你也在做一份内容、多种导出的产品,可以来 md-to.com 试试这套自定义模板,看看它是不是你想要的那种”刚刚好”的自由度。
相关工具
免费 Markdown 转 Word 转换器 - 在线将 MD 转换为 DOCX
免费在线 Markdown 转 Word 转换器,实时预览,一键下载 DOCX 文档。完美保留格式,无需注册,本地处理保护隐私。立即体验!
免费 Word 转 Markdown 转换器 - 在线将 DOCX 转换为 MD
免费在线将 Word 文档转换为 Markdown。DOCX 转 MD 转换器完美保留标题、表格、列表和格式。无需注册,立即体验!
免费 Markdown 转 PDF 转换器 - 在线将 MD 转换为 PDF 文件
免费在线将 Markdown 转换为高质量 PDF 文件。智能分页引擎自动处理代码块截断和表头跨页问题,20 多种专业模板可选(GitHub 风格、学术、商务),支持 GFM 语法、KaTeX 数学公式、语法高亮,100% 浏览器本地处理,无需注册,保护隐私。
免费 Markdown 转 LaTeX 工具 - 在线将 MD 转换为 TeX
在线免费将 Markdown 转换为 LaTeX。将 MD 文档转换为 TeX 格式,适合学术论文和技术文档。无需注册!