iText实现Markdown转PDF完整流程:HTML桥接与字体排版实战
发布时间:2026/10/20 21:04:36 锦皓数字建站

收到我会严格按照要求基于“ITextpdf将Markdown转换成PDF详细流程HTML转PDF”这一标题撰写一篇结构独立、内容详实、可直接复现的实战型博文。以下为纯净的Markdown正文内容。1. 为什么绕这么大一圈Markdown到PDF的真实链路先说一个反直觉的结论iText本身并不知道Markdown是什么。它最擅长的输入格式是PDF语法级别的元素而对外暴露的便捷API则更多围绕HTML/XHTML展开。所以当需求是“把Markdown转成PDF”时最稳妥、最省力的技术路线不是让iText去解析Markdown而是先把Markdown解析成HTML再把HTML渲染成PDF。这一步绕路不是多此一举而是把两个成熟工具的各自最佳能力组合起来Markdown的解析交给专门的解析器PDF的渲染交给iText。我在实际项目里遇到这个需求的场景很典型某内部工具平台需要把一批由运营同学维护的Markdown说明文档批量导出为PDF用于线下分发和归档。文档里包含标题、列表、表格、代码块、引用块、加粗、链接这些最常见的Markdown元素整体复杂度不高但量比较大一天要跑几百份。如果用手工复制到在线编辑器里导出PDF效率太低如果有几十份文档格式还不一样维护成本就完全失控了。于是我把整条链路拆成了Markdown转HTML、HTML转PDF两个阶段最终用一套Java程序搞定全流程。值得说明的是这条链路里有两个“桥接点”决定了最终的成品质量。第一个桥接点是Markdown解析器的选择它必须能够正确输出与源文档语义一致的HTML片段否则后面PDF还原度就无从谈起。第二个桥接点是iText渲染HTML的CSS支持能力iText的pdfHTML模块对CSS的支持约等于一个小型浏览器核心它支持常见的盒模型、字体样式、颜色、表格边框等属性但低于CSS 2.1的某些规则也未必支持得很完美。理解了这两个边界剩下的工作就变成了“在边界内合理配置把常见文档场景逐一跑通”。所以这篇文章不是简单的代码粘贴而是一条从选型、配置、编码到踩坑的完整实战记录适合正准备做类似功能的开发者参考尤其是那些对iText不熟悉、又不想来回试错的初学者。2. 环境准备与依赖选型开源库之间的关键取舍先把目标环境说清楚。我用的是JDK 8 Maven 3.6 iText 7.x pdfHTML 2.x这套组合在目前的开源生态里属于黄金搭配。很多人一上来会用iText 5的XMLWorker那套方案其实也能用但它对CSS 2.1的支持更弱而且官方早已停止维护遇到新的HTML结构会越来越吃力。iText 7的pdfHTML模块则是官方持续维护的产品对HTML5结构、内联CSS的解析都要好得多对中文乱码问题的处理也更干净。依赖引入方面Maven坐标如下dependency groupIdcom.itextpdf/groupId artifactIdkernel/artifactId version7.2.5/version /dependency dependency groupIdcom.itextpdf/groupId artifactIdio/artifactId version7.2.5/version /dependency dependency groupIdcom.itextpdf/groupId artifactIdlayout/artifactId version7.2.5/version /dependency dependency groupIdcom.itextpdf/groupId artifactIdpdfhtml/artifactId version4.0.5/version /dependency这五个依赖是最小集合。pdfhtml内部会传递依赖一些工具包比如html2pdf公共模块不需要我们再额外声明。如果只是想跑通一个最小Demo最终编译时也就这五个jar包加上commons-logging这类传递依赖而已体积不算大。接下来是Markdown解析器的选型。Java生态里有几个选择commonmark-java、flexmark、pegdown、markdown4j。我在这里强烈建议用commonmark-java原因有三个。第一它的实现遵循CommonMark规范是目前社区兼容性最好的Markdown方言GitHub风格的表格和任务列表虽然不在CommonMark核心规范里但可以通过扩展模块拿到支持。第二它的API干净解析AST再导出HTML只需要几行代码没有那么多包袱。第三新版对并发解析的支持不错在高并发批量导出场景下性能稳定。dependency groupIdorg.commonmark/groupId artifactIdcommonmark/artifactId version0.21.0/version /dependency dependency groupIdorg.commonmark/groupId artifactIdcommonmark-ext-gfm-tables/artifactId version0.21.0/version /dependency dependency groupIdorg.commonmark/groupId artifactIdcommonmark-ext-gfm-strikethrough/artifactId version0.21.0/version /dependency dependency groupIdorg.commonmark/groupId artifactIdcommonmark-ext-autolink/artifactId version0.21.0/version /dependency依赖确定之后还建议再检查一下字体环境这块内容我会在后文专门讲。现在先把 “Markdown到HTML” 这半场跑通。3. Markdown到HTML这半步藏着最多的定制空间很多人以为“Markdown转HTML”就是个库一把梭的事情实际上真正写起来会发现解析器给了你标准的HTML但PDF渲染对HTML的整洁度很挑剔。我把这段流程分成两步第一步用commonmark-java将Markdown解析为HTML字符串第二步用Jsoup对HTML做一次“净化和微整形”。3.1 基础解析commonmark-java的完整接入核心代码不复杂但有一个关键点是解析器构建。直接看代码import org.commonmark.Extension; import org.commonmark.ext.autolink.AutolinkExtension; import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension; import org.commonmark.ext.gfm.tables.TablesExtension; import org.commonmark.node.Node; import org.commonmark.parser.Parser; import org.commonmark.renderer.html.HtmlRenderer; import java.util.Arrays; import java.util.List; public class MarkdownToHtmlConverter { private static final ListExtension EXTENSIONS Arrays.asList( TablesExtension.create(), StrikethroughExtension.create(), AutolinkExtension.create() ); private final Parser parser; private final HtmlRenderer renderer; public MarkdownToHtmlConverter() { this.parser Parser.builder() .extensions(EXTENSIONS) .build(); this.renderer HtmlRenderer.builder() .extensions(EXTENSIONS) .escapeHtml(true) .build(); } public String convert(String markdown) { Node documentNode parser.parse(markdown); return renderer.render(documentNode); } }这里有两个细节值得展开说。第一个是escapeHtml(true)。默认情况下commonmark-java的HTML渲染器会原样保留Markdown里写的HTML标签这在下游传给iText时可能出现样式冲突或安全问题。比如用户写了一个div stylepage-break-before: always;这个东西到底该不该出现在PDF里我更倾向于让业务侧的Markdown保持纯粹所以开启转义让所有HTML标签都变成纯文本展示这样PDF输出的样式完全由渲染模块统一控制。如果你的场景确实需要允许用户塞自定义HTML那把这个参数改成false即可但要做好XSS方面的心理准备。第二个细节是表格扩展。表格是Markdown转PDF流程中容错率最低的元素因为PDF渲染引擎对表格宽度、边框合并的支持差异极大。commonmark-java的TablesExtension会把Markdown表格转成table这是一个好消息坏消息是它默认不生成行内样式表格边框、宽度这些最后都要靠CSS补充。我后文会讲怎么用CssStyleTag补齐这里只需要确认解析出的table结构正确即可。3.2 净优化为什么解析完之后还要动一次HTML我接手的文档里有相当一部分是运营同学直接从平台里“复制粘贴”到Markdown源文件里的里面残留了很多不必要的空段落、多余的空格和换行。这些内容在浏览器里看问题不大但在PDF里会导致莫名其妙的大片空白。所以我在解析器返回HTML字符串之后会再用Jsoup做一次轻量清洗。只做三件事去掉所有空p标签把连续的空行折叠成一个去掉HTML注释如果存在img标签检查图片路径是否可达不可达的直接替换成占位文本。这一步不追求把HTML改得天翻地覆只求它干净、稳定、不干扰后续的渲染。代码示例如下import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.select.Elements; public class HtmlCleaner { public static String clean(String rawHtml) { Document doc Jsoup.parse(rawHtml); doc.select(p:empty).remove(); doc.select(comment).remove(); Elements images doc.select(img[src]); for (Element img : images) { String src img.attr(src); if (!src.startsWith(http) !new java.io.File(src).exists()) { img.replaceWith(new Element(span).text([图片缺失: src ])); } } return doc.body().html(); } }这里需要小心一点Document对象本身会包含html和head骨架所以不能直接doc.html()返回否则会在后面插入到iText的HTML解析里产生奇怪的空字符。必须用doc.body().html()拿到body内部的HTML片段然后由我们自己在最终HTML模板里拼装。3.3 完整HTML模板的拼装“姿势”拿到干净的HTML片段后需要把它嵌入一个完整的HTML文档里。这一步不是简单拼接字符串而是要给后续的PDF渲染留好定制接口。我的推荐做法是用一个Freemarker或者纯字符串模板把CSS和HTML片段同时注入。模板大概是这样的结构!DOCTYPE html html head meta charsetUTF-8 style body { font-family: SourceHanSansCN, sans-serif; font-size: 11pt; line-height: 1.6; margin: 0; padding: 1.5em 1.8em; color: #24292e; } /* 这里会放针对表格、代码块、引用块的定制样式 */ /style /head body ${htmlFragment} /body /html注意几点。meta标签的charset要显式声明成UTF-8因为iText在处理HTML到PDF转换时对编码非常敏感缺少这个声明会直接导致中文乱码。body的margin和padding不要设置成0否则PDF顶部文字会顶到页面边缘打印感很差。字体方面我在font-family里指定了思源黑体的名字然后通过FontProvider注册实际的字体文件具体做法在后文展开。样式放在head的style标签里而不是依赖外部CSS文件因为iText的pdfHTML默认不会去外部服务器拉取CSS资源虽然它有资源Resolver的接口可以定制但为了减少排查范围直接内联样式是最可靠的。4. iText渲染HTML到PDF核心代码与字体不死之谜搞定前半场之后后半场就交给iText。这一节的代码其实很短但每一个配置参数背后都有坑。4.1 最小可运行的HTML转PDF代码直接用iText 7 pdfHTML的API代码如下import com.itextpdf.html2pdf.ConverterProperties; import com.itextpdf.html2pdf.HtmlConverter; import com.itextpdf.io.font.FontProgram; import com.itextpdf.io.font.FontProgramFactory; import com.itextpdf.kernel.font.PdfFont; import com.itextpdf.kernel.font.PdfFontFactory; import com.itextpdf.kernel.geom.PageSize; import com.itextpdf.kernel.pdf.PdfDocument; import com.itextpdf.kernel.pdf.PdfWriter; import com.itextpdf.layout.font.FontProvider; import com.itextpdf.layout.font.FontSet; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; public class HtmlToPdfConverter { private String fontPath; private FontProvider fontProvider; public HtmlToPdfConverter(String fontPath) throws IOException { this.fontPath fontPath; this.fontProvider new FontProvider(); // 注册中文字体 this.fontProvider.addFont(fontPath); // 设置默认字体族回退 this.fontProvider.setDefaultFontFamily(SourceHanSansCN); } public byte[] convert(String htmlContent) throws IOException { ByteArrayOutputStream out new ByteArrayOutputStream(); PdfWriter writer new PdfWriter(out); PdfDocument pdfDoc new PdfDocument(writer); // 设置页面大小这里使用A4 pdfDoc.setDefaultPageSize(PageSize.A4); ConverterProperties props new ConverterProperties(); props.setFontProvider(fontProvider); props.setCharset(UTF-8); try (InputStream in new ByteArrayInputStream(htmlContent.getBytes(StandardCharsets.UTF_8))) { HtmlConverter.convertToPdf(in, pdfDoc, props); } finally { pdfDoc.close(); } return out.toByteArray(); } }这段代码看起来简单但有一个核心问题比代码本身更重要它对转换结果有决定性的影响我单独拿出来讲。4.2 字体中文不乱码的唯一秘密先说结论iText的pdfHTML模块渲染HTML时如果遇到字体表中没有覆盖的字符默认行为是渲染成方块空白不报错。这是绝大多数“HTML转PDF中文全变方块”问题的唯一根因。解决方案就是显式注册覆盖中文的字体并且在CSS里指定以该字体为最终回退。我使用思源黑体作为项目字体原因有两个真正开源、可商用字体文件可以直接打包到项目里字形覆盖范围大尤其在文泉驿正黑、微软雅黑之外它对于生僻字的覆盖也足够日常技术文档。字体文件路径在项目里要注意别用new File()的方式加载因为打包成jar之后文件路径会失效更稳的方式是从classpath读取为字节数组再交给FontProgramFactory.createFont。示例private void loadFontFromClasspath() throws IOException { InputStream is getClass().getResourceAsStream(/fonts/sourcehansanscn-regular.otf); byte[] fontBytes is.readAllBytes(); FontProgram fontProgram FontProgramFactory.createFont(fontBytes); fontProvider.addFont(fontProgram); fontProvider.setDefaultFontFamily(SourceHanSansCN); }同时在HTML模板的CSS里必须指定font-family: SourceHanSansCN, sans-serif;这个字体名必须与字体程序内部的PostScript name或family name一致。检查字体内部名称的方式很简单用系统自带字体查看器打开字体文件看“字体名称”一栏或者用FontForge导出name table。如果名字对不上iText就会把这个字体当成无关字体丢弃最终仍然回退到内置的无中文基础字体。4.3 页面布局页边距、页眉页脚的合理设置如果是简单的一张A4纸保持PageSize.A4默认页边距还能接受但真实文档需求往往需要更精细的设置。pdfHTML的ConverterProperties本身不直接提供页边距配置正确的做法是使用PdfDocument的自定义页面事件在每个onEndPage阶段调整页面大小或绘制页眉页脚。简单示例pdfDoc.addEventHandler(PdfDocumentEvent.END_PAGE, new PdfDocumentEvent() { Override public void onEndPage(PdfDocumentEvent event) { PdfPage page event.getPage(); PdfDocument doc event.getDocument(); Rectangle pageSize page.getPageSize(); PdfCanvas canvas new PdfCanvas(page.newContentStreamAfter(), page.getResources(), doc); canvas.beginText() .setFontAndSize(PdfFontFactory.createFont(), 8) .moveText(pageSize.getLeft() 36, pageSize.getBottom() 20) .showText(第 doc.getPageNumber(page) 页) .endText(); canvas.release(); } });这个事件处理方案有一个隐含前提必须在HTMLConverter.convertToPdf调用之前挂好事件否则END_PAGE事件不会触发。我在初版代码里就是把这个事件放在转换之后才添加结果页面事件完全没生效排查了半天才发现顺序问题。如果你只需要简单的页脚并且不追求复杂排版用这个方案最省事。如果需要封面、目录、多章节的复杂排版建议还是将整篇文档拆成多段HTML分多次调用HtmlConverter写入同一个PdfDocument事件机制依然有效。5. CSS支持边界与表格、代码块的渲染调优iText pdfHTML的CSS支持范围基本上属于“够用但要克制”的范畴。我在踩坑过程中总结了一张兼容性速查表实测稳定可用的属性如下样式功能CSS属性支持情况与备注字体样式font-family, font-size, font-weight, font-style完全支持注意字体名称映射文本颜色color完全支持行距line-height支持推荐1.5~1.8段落空白margin, padding支持但嵌套多层时可能累积叠加背景颜色background-color支持只支持纯色边框border, border-radius支持border-radius在表格单元格里可能失效列表list-style-type支持但自定义图片样式不支持表格布局width, border-collapse支持border-collapse: collapse效果较稳页面分页page-break-before / page-break-after支持重点用于代码块和引用块前的换页Floatfloat支持不稳定建议避免Flex / Grid所有属性完全不支持渲染会变成文档流顺序伪元素::before, ::after不支持媒体查询media不支持核心结论是在设计模板时尽量用最朴素的块级元素布局不要依赖Flex或Grid否则pdfHTML会把元素当作普通块级流渲染结果往往是内容堆叠得像一张乱码电报。表格是个重点项。Markdown的表格经过commonmark-java转成table后默认不带宽度和边框直接渲染出来的PDF观感比较“素”。我通常在CSS里给表格补齐边框和宽度table { width: 100%; border-collapse: collapse; margin: 1em 0; } th, td { border: 1px solid #d0d7de; padding: 4px 8px; text-align: left; vertical-align: top; } th { background-color: #f6f8fa; font-weight: 600; }按我实际测试border-collapse: collapse是最稳的表格方案单独设置单元格边框间距会出现重叠黑边。另一个注意点是pdfHTML对单元格内长文本的自动换行做得不错但如果你在Markdown表格的单元格里塞了一个很长的URL它默认不自动截断会强行溢出到下一页或挤压整行最好在CSS里给td补一条word-break: break-all;或overflow-wrap: break-word;。代码块的处理也比较特殊。commonmark-java对代码块默认输出precode classlanguage-javaiText的pdfHTML默认情况下不会为pre提供等宽字体这时候需要给pre和code显式指定等宽字体。环境里如果安装了Courier New可以直接用没有的话我会在FontProvider里额外注册一份免费的JetBrains Mono字体并在CSS里写pre, code { font-family: JetBrains Mono, Courier New, monospace; font-size: 9.5pt; } pre { background-color: #f6f8fa; padding: 10px 12px; border-radius: 4px; overflow-x: hidden; }注意这里overflow-x实际上不生效因为pdfHTML不支持横向滚动它会直接把内容换行。所以如果你的代码行非常长需要提前做好换行处理或者在Markdown侧就主动控制代码行宽。这是我在实际导出某一本操作手册时踩到的最大坑一段120字符长的SQL渲染到PDF后被截断读者根本看不到后半段。6. 批量导出时的性能与稳定性经验需求往往是“一次导出上百个Markdown”。最初我是在循环里创建了新的PdfWriter和PdfDocument跑几十份没问题但跑到两百份左右时内存几乎被吃满GC频繁。后来我做了两个优化。第一个优化是复用FontProvider。也就是说字体加载是一次性的不要放在convert方法内部因为每次FontProvider.addFont()都会重新解析字体文件开销极大。我把FontProvider作为成员变量或者放进一个单例服务里多个转换任务共用。第二个优化是控制PdfDocument的生命周期。如果循环里每份文档都新建PdfDocument并close这本身没有问题但要注意HtmlConverter.convertToPdf的底层实现默认会创建一个WorkerContext这个上下文内部持有一些缓存对象。我将循环内每次转换后的ByteArrayOutputStream及时释放并显式调用System.gc()是不可取的更好的做法是观察线程栈判断是否因HtmlConverter并发实例化导致线程切换开销过大。一个更实用的经验是将大批量任务改成分批处理每批生成一个临时文件放在磁盘上全部完成后统一合并或者逐个返回。这样做的原因在于iText的PdfDocument在close之后会触发大量对象释放如果循环里同时保留几十个PdfDocument引用GC压力很大。分批遍历每批只保留一个文档引用内存占用量会稳定很多。我给出一个简化的批量接口设计参考public Listbyte[] batchConvert(ListString markdownList, MarkdownToHtmlConverter mdConverter, HtmlToPdfConverter pdfConverter) { Listbyte[] result new ArrayList(); for (int i 0; i markdownList.size(); i) { String markdown markdownList.get(i); String html mdConverter.convert(markdown); html HtmlCleaner.clean(html); html wrapIntoTemplate(html); byte[] pdfBytes pdfConverter.convert(html); result.add(pdfBytes); // 每50份休眠50ms避免CPU过热或频繁GC if ((i 1) % 50 0) { try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } return result; }真实场景里如果对性能要求更高可以考虑并行化。我测试过ExecutorServiceCallable并行转换效果不错但需要注意此时MdToHtmlConverter和HtmlCleaner必须是无状态单例HtmlToPdfConverter的FontProvider线程安全因为它们内部只做只读操作。如果中间引入了共享可变对象不建议并行。7. 排查链路实录中文乱码、样式丢失、长文本截断最后这部分记录我在调试过程中遇到过的三类高频问题每个问题都是从一个错误现象出发一步步排查到根因的完整链路这对你后续使用会有直接帮助。7.1 中文变成“方块字”的完整排查第一次跑出PDF时正文里的中文全部变成矩形空格英文和数字正常。这个现象其实已经把问题范围缩小到字体覆盖而不是解析层面。我的排查链路是先检查ConverterProperties是否设置了正确的charset。如果没有中文会变成问号或者乱码而不是方块。这里现象是方块所以排除charset问题。检查FontProvider是否添加了中文字体。如果没添加字体回退会落到默认字体必然缺字。但这一步我已经添加了思源黑体按理说不该有问题。继续查CSS里font-family写的名字和FontProgram里的族名是否一致。我用系统字体查看器打开思源黑体字体文件发现它的族名是“Source Han Sans CN”不是“SourceHanSansCN”而我在CSS里写的却是后者。两边不一致iText自然匹配不上。修正字体名之后中文显示就正常了。这个坑很隐蔽因为字体族名有时候带空格有时候没有拷贝时很容易丢。7.2 样式完全丢失却连报错都没有另一类棘手问题是HTML里的加粗、标题、表格边框全部丢失整个PDF变成一坨没有层次感的纯文本。我第一次遇到时以为是CSS写法问题加了各种前缀都没用。后来一步步排查才发现问题出在HTML模板的拼装我是用String.format()往模板里填HTML片段但片段里有大量%字符比如CSS里的百分比宽度直接导致String.format抛异常或者把后面的参数吞掉。正确做法是在拼HTML模板时使用String.replace()或Freemarker这类模板引擎不要用String.format去拼接包含CSS的内容。否则一个小小的%就会让样式系统彻底亚健康。遇到类似现象第一件事不是去查CSS写法而是看拼装之后的完整HTML输出是否和预期一致。7.3 长URL与长代码行的硬截断上文已经提到代码块的overflow-x不生效。实际上不止代码块任何无空格的长字符串在pdfHTML中都会拒绝自动换行。排查过程是这样的某份文档的表格里有一个很长的API地址PDF输出里那一行直接向右溢出页面边界后面的列被挤到了页面外。我用Chrome预览同样HTML是正常的因为浏览器默认会自动截断长URL。解决方案是在CSS里设置word-break: break-all;同时对pre和td都加上这个规则。这个规则对中文影响不大因为中文字符本身可以逐字换行它主要对连续的英文字母和数字有效。加上之后长URL和长代码都能正常折行版面的完整度有了质的提升。7.4 分页踩踏标题和正文被硬生生切开批量导出操作手册时我发现一个标题正好落在页面底部标题下方的内容却跑到了下一页观感很糟。原因是page-break-after: avoid这个属性在pdfHTML里对标题块的支持不稳定。我的处理方式是对所有h1、h2、h3统一添加page-break-before: auto; page-break-after: avoid;同时对代码块和表格包裹一层带page-break-inside: avoid的div。实际测试下来page-break-inside: avoid对表格的兼容性比标题块的page-break-after: avoid更稳定最终效果是表格不再跨页断裂阅读体验好很多。h1, h2, h3 { page-break-after: avoid; } pre, table { page-break-inside: avoid; }这套组合实测下来基本能达到“非必要不跨页”的效果。如果想再细调还可以给每个大章节前的h1加上page-break-before: always强制从新页开始。8. 收尾几个值得长期保留的实战习惯项目走到这里整条链路已经稳定跑了一段时间我把自己沉淀下来的习惯写在这里。第一永远保留一份“脏HTML”检查工具。我在项目里留了一个简单的Servlet接口接收任意Markdown返回拼接后的HTML预览这样测试新样式时不用每次跑完整PDF流程先在浏览器里确认样式无大碍再走iText转换。这个接口省了大量调试时间。第二把字体文件打进jar包而不是依赖服务器系统字体。大多数Linux服务器默认是没有中文字体的如果只在本机开发时把字体放到某个绝对路径部署到生产环境必然翻车。一直用classpath方式读取字体文件配合FontProgramFactory.createFont(byte[])这是最稳的组合。第三对Markdown源文件做“健康检查”。在转换前用一个正则或者简单规则过滤掉空文档、超大代码块和异常表格避免iText因为异常结构生成错误PDF时我们难以后续排错。这听起来不复杂但真实运营环境里用户输入的无规律程度远超你的想象。第四给整个转换任务加上超时和降级策略。比如单份文档超过30秒就记录日志并跳过而不是让整个批量任务卡死在某个诡异的HTML结构上。iText面对某些畸形HTML时偶尔会出现解析器重试导致的卡顿超时策略能有效保护整体流程。这条链路从最初的手工复制粘贴到现在的全自动批量导出代码量不到三百行却解决了每天几百分文档的导出需求。希望这篇文章能让你少走几个我踩过的坑尤其在字体、CSS兼容性和排错思路上直接把这些经验用到你自己的项目里去。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。