做Java后端的人,几乎没有不认识Apache POI的。我用它做Office文档解析、生成和模板填充,最高频的场景就是操作Word。最近接到一个新需求,客户要求导出的Word报告必须是A4纸张、上下左右边距固定,直接把页面设置写死在程序里。看似简单,但真正在poi 5.2.2操作Word时,纸张大小和边距的配置藏在底层XML里,不少人在这一步就卡住了。这篇内容,我按自己完整做过一遍的路子来梳理:去哪里拿pageSize、怎么设纸张、怎么设边距、以及各种单位错乱和多节文档的坑。写完你可以直接用这套代码生成一份页面规整的Word文档,也适合那些做自动报表、合同导出、打印单据生成的Java开发者参考。
1. 先说清楚:纸张和边距在Word里到底存在哪
1.1 为什么页面设置的关键是“节”
很多人上手就找“设置页面大小”的API,结果发现XWPFDocument里并没有setPageSize这样的方法。这是因为Word的文档模型里,页面尺寸和边距并不属于某个“页面”,而是属于“节(Section)”。一个Word文档可以只有一个节,也可以有多个节,每个节都可以有自己的纸张大小、方向、边距、页眉页脚等设置。分节符前后的内容,互不影响。
可以这么理解:如果把一篇文档比作一套房子,节就是一个个独立的房间,纸张和边距是这个房间的“墙线”和“家具边界”。打开Word的“布局”选项卡,或者在“页面设置”里看到“应用于整篇文档”还是“插入点之后”,本质上就是在跟节打交道。如果你只修改某一段之后的页面设置,Word其实会自动在该位置插入一个分节符。
所以在POI里,思路很直接:想办法拿到目标节的sectPr(Section Properties),再修改里面的pgSz(Page Size)和pgMar(Page Margin)。本文后续所有代码,底层操作的都是这两个XML节点。只要理解了这一层,再去看那些五花八门的POI报错信息,基本都能自己定位问题。
1.2 POI 5.2.2的底层对象关系
Apache POI处理Word的OOXML格式,对外暴露的是XWPFDocument、XWPFParagraph、XWPFTable这些“高级对象”,但页面设置这种接近元数据的东西,藏在底层XML里。具体关系是这样:
- XWPFDocument.getDocument()拿到org.openxmlformats.schemas.wordprocessingml.x2006.main.Document,也就是CTDocument;
- CTDocument.getBody()拿到CTBody,这是文档正文容器;
- CTBody.getSectPr()拿到CTSectPr,它就是节的属性对象;
- CTSectPr里有两个关键子对象:CTPageSz(对应XML节点 w:pgSz)和CTPageMar(对应 w:pgMar)。
所以一条调用链路是:
CTSectPr sectPr = document.getDocument().getBody().getSectPr();拿到sectPr之后,纸张大小和边距都通过它来改。这个命名确实有点绕,但把闭环理解之后,再去看官方API和源码就不会发懵。CTSectPr、CTPageSz这些类都是schemas的生成类,方法名直白,比如getPgSz()、addNewPgSz()、setW(BigInteger)。实际使用中不需要记住全部,掌握常用的几个就够了。
2. 环境准备与核心API速览
2.1 Maven依赖与版本选择
我用的是Apache POI 5.2.2,依赖坐标如下:
<dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.2</version> </dependency>选5.2.2而不是更早的4.x,主要考虑三点:一是5.2.x属于较新的稳定版本,对JDK8及以上支持很成熟;二是很多封装POI的工具库(比如各类word模板引擎)都以5.x为基准,版本兼容性更好;三是在xmlbeans的依赖管理上,5.2.2内部协调得比较顺,基本一个依赖就能把poi核心和ooxml相关类都带进来,不太会出现类冲突。
有一点要特别提醒:只引入poi这个构件是不包含XWPF相关类的,必须引入poi-ooxml。导入之后,XWPFDocument、CTSectPr这些类就在classpath里了。
2.2 核心API对照与单位换算
先给一张我在做页面设置时常用的API对照表,方便后面看代码:
| 类/接口 | 常用方法 | 作用 |
|---|---|---|
| XWPFDocument | getDocument() | 获取底层CTDocument |
| CTBody | getSectPr() / addNewSectPr() | 获取或新建节属性 |
| CTSectPr | getPgSz() / addNewPgSz() | 获取或新建纸张大小对象 |
| CTSectPr | getPgMar() / addNewPgMar() | 获取或新建页边距对象 |
| CTPageSz | setW(BigInteger) / setH(BigInteger) | 设置页面宽、高(单位twips) |
| CTPageSz | setOrient(STPageOrientation) | 设置页面方向 |
| CTPageMar | setTop/setBottom/setLeft/setRight | 设置上下左右边距(单位twips) |
| CTPageMar | setHeader/setFooter/setGutter | 设置页眉页脚边距与装订线 |
接下来说单位,这是最容易出错的地方。OOXML页面设置里的所有尺寸,单位都是twips,英文也叫“缇”。这个单位的概念是:1英寸等于1440 twips,1厘米约等于566.929 twips,1毫米约等于56.6929 twips。
所以当你拿到一个边距值“1440”时,别把它当成像素或者磅,它其实就是2.54厘米。我习惯在代码里放两个换算方法,后面到处都用得到。常用纸型对照表也列一下:
| 纸型 | 宽度(twips) | 高度(twips) | 对应毫米 |
|---|---|---|---|
| A4 | 11906 | 16838 | 210×297 |
| A3 | 16838 | 23811 | 297×420 |
| Letter | 12240 | 15840 | 216×279 |
| B5 | 9984 | 14172 | 176×250 |
注意横向时宽高需要互换,这个在3.2节细说。
3. 实战:设置纸张大小
3.1 A4纵向:最常用的配置
直接上代码。声明一个XWPFDocument,往里面随便写一行字,然后设置A4纵向:
import java.io.FileOutputStream; import java.math.BigInteger; import org.apache.poi.xwpf.usermodel.XWPFDocument; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageSz; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSectPr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STPageOrientation; public class WordPageSetupDemo { public static void main(String[] args) throws Exception { XWPFDocument doc = new XWPFDocument(); doc.createParagraph().createRun().setText("页面设置示例"); // 拿到节配置,没有就新建 CTSectPr sectPr = doc.getDocument().getBody().getSectPr(); if (sectPr == null) { sectPr = doc.getDocument().getBody().addNewSectPr(); } // 纸张大小:A4纵向,宽11906,高16838 CTPageSz pageSize = sectPr.isSetPgSz() ? sectPr.getPgSz() : sectPr.addNewPgSz(); pageSize.setW(new BigInteger("11906")); pageSize.setH(new BigInteger("16838")); pageSize.setOrient(STPageOrientation.PORTRAIT); try (FileOutputStream out = new FileOutputStream("a4_portrait.docx")) { doc.write(out); } } }这里有个细节:new XWPFDocument()创建出的空文档,body里不一定存在sectPr节点。所以稳妥写法是先getSectPr()判空,为空再addNewSectPr()。很多帖子里直接调sectPr.getPgSz()结果空指针,原因就在这里。
A4的宽度11906来自210×56.6929约等于11906,高度16838来自297×56.6929约等于16838。不用死记,后面我会给出标准换算方法,放到工具类里直接调用即可。
3.2 A3横向:别忘了同时交换宽高
横向是另一个高频需求。打印票据、宽表格、折线图报表,经常要把A3横着放。代码里需要注意的点比较多,先看一个容易犯错的写法:
CTPageSz pageSize = sectPr.isSetPgSz() ? sectPr.getPgSz() : sectPr.addNewPgSz(); pageSize.setW(new BigInteger("23811")); pageSize.setH(new BigInteger("16838")); pageSize.setOrient(STPageOrientation.LANDSCAPE);如果你把A3纵向的原始尺寸直接照搬,再配上LANDSCAPE,那确实是一个“横向的笨蛋”:宽高没有交换,方向虽然标了横向,但页面还是竖版的比例。正确做法是,横向时宽度等于原来纵向的高度,高度等于原来纵向的宽度:
// A3横向:宽=16838(原高),高=23811(原宽) pageSize.setW(new BigInteger("16838")); pageSize.setH(new BigInteger("23811")); pageSize.setOrient(STPageOrientation.LANDSCAPE);换A4横向同理:宽16838,高11906。记住一句口诀:横向就是宽高互换,再加orient等于LANDSCAPE。顺序不重要,最终宽高必须是互换后的值。
为什么必须互换?因为OOXML的w:pgSz节点里,w属性永远表示“当前方向下的页面宽度”。如果不交换,Word打开后会看到页面方向是横向的,但内容区域的长宽比不对,打印出来更是错乱。这个坑我在生成报告时踩过一次,当时排查了半天,最后用压缩工具打开document.xml才发现w和h没有交换。
3.3 自定义纸张:从“毫米”到twips的换算
有些场景用不到A4这种标准纸型,比如打印送货单、标签纸、卷式小票。常见的是自定义纸张,比如100mm×150mm的小卡片。这时最靠谱的方式是提供毫米值,通过公式换算成twips。
换算逻辑很简单:
- 1英寸 = 25.4毫米
- 1英寸 = 1440 twips
所以毫米转twips的工具方法可以这样写:
public static BigInteger mmToTwips(double mm) { double twips = mm * 1440.0 / 25.4; return BigInteger.valueOf(Math.round(twips)); }用这个方法设置自定义纸型:
CTPageSz pageSize = sectPr.isSetPgSz() ? sectPr.getPgSz() : sectPr.addNewPgSz(); pageSize.setW(mmToTwips(100)); // 宽100mm pageSize.setH(mmToTwips(150)); // 高150mm pageSize.setOrient(STPageOrientation.PORTRAIT);注意Math.round的使用,因为结果是浮点数,直接强转long会丢掉精度。BigInteger.valueOf的参数是long,正好接收Math.round的返回值。实测下来,四舍五入后的值不会出现边界上的“差1”问题,而直接用double强转long,在极端情况下可能出现舍入不一致。
补充一点:Word的自定义纸张尺寸是有范围限制的(大约是0.1英寸到22英寸之间),超出范围后Word打开会提示“页面设置无效”。所以对用户输入尺寸最好做校验,比如高度和宽度至少大于1mm,否则生成的文档可能在别人的电脑上打不开。
4. 实战:设置页边距
4.1 快速设置四边等距
纸张设完,接下来是页边距。还是通过CTSectPr里的CTPageMar来做。如果要统一四边边距,比如都是2厘米:
CTPageMar pageMar = sectPr.isSetPgMar() ? sectPr.getPgMar() : sectPr.addNewPgMar(); pageMar.setTop(mmToTwips(20)); pageMar.setBottom(mmToTwips(20)); pageMar.setLeft(mmToTwips(20)); pageMar.setRight(mmToTwips(20));这里用到的setTop/setBottom/setLeft/setRight,单位同样是twips。Word的默认边距是上下2.54cm,左右3.17cm,用twips表示就是上下1440、左右1800。如果你只想改某一边,比如让右边距窄一点容纳表格,也可以单独setRight,其他边保持原值。
建议不要一次性把这四个值全部写死成相同尺寸,除非业务明确要求。很多导出文档打开后看起来内容挤在左侧,多半就是左边距和右边距设成了不合适的绝对值,忽略了Word默认的装订线或页边距差异。
4.2 分项设置边距与页眉页脚间距
CTPageMar不止上下左右,它还包含header、footer、gutter三个字段。header表示页眉区域距离页面顶部的距离,footer表示页脚区域距离页面底部的距离,gutter是装订线预留边距。
假如文档需要打印后装订,预留0.8cm装订线,并且页眉距离顶部1.5cm:
pageMar.setGutter(mmToTwips(8)); pageMar.setHeader(mmToTwips(15)); pageMar.setFooter(mmToTwips(15));这三个字段不常被注意到,但在做正式合同、标书、毕业论文这类文档时,装订线和页眉距离经常是硬性要求。我在处理一个客户的标书模板时,就差在gutter没设置,导致装订后左侧文字被遮住。后来把gutter设成8mm,再配合左侧边距适当缩小,问题就解决了。
需要留个心:header和footer的值会影响Word中页眉页脚的可视范围。如果页眉里放了内容,但header值太小,会出现页眉和正文打架的现象。这类问题去检查页边距里的header属性,比在页眉内容上瞎调试要快得多。
4.3 基于现有模板微调边距
很多时候不是从零创建Word,而是读取一个模板docx,只改边距然后另存为。这时最好先读出现有值,再按需修改:
CTSectPr sectPr = doc.getDocument().getBody().getSectPr(); if (sectPr != null && sectPr.isSetPgMar()) { CTPageMar pageMar = sectPr.getPgMar(); // 打印现有的上边距,注意转回毫米方便观察 System.out.println("top=" + twipsToMm(pageMar.getTop().longValue()) + " mm"); // 修改左边距为2cm pageMar.setLeft(mmToTwips(20)); } else { // 没有边距定义就直接新建 sectPr.addNewPgMar(); sectPr.getPgMar().setLeft(mmToTwips(20)); }这里的twipsToMm是反向换算:
public static double twipsToMm(long twips) { return twips * 25.4 / 1440.0; }模板微调的坑在于:模板可能有多个节。如果body.getSectPr()返回null,不代表全文没有边距,只是说最后一个节没有显式sectPr,而中间某个节可能定义了独立边距。这个问题专门放到第5章讲。
5. 常见问题与排查技巧实录
5.1 单位搞错:打印出来尺寸不对
症状:生成文档在Word里看着是A4,打印出来却比A4小一圈,或者页边距明显偏大/偏小。
原因:把毫米、厘米、磅这些单位,当成twips填进了setW/setH/setTop。比如有人想设2cm边距,直接setTop(2),结果得到的是2 twips,约0.0035厘米,几乎看不见边距;反过来写setTop(200),也会差出一个数量级。
排查方法:生成docx后,用压缩工具打开,找到word/document.xml,搜索pgSz和pgMar两个节点。比如:
<w:pgSz w:w="11906" w:h="16838"/> <w:pgMar w:top="1440" w:right="1800" w:bottom="1440" w:left="1800" w:header="720" w:footer="720" w:gutter="0"/>对照之前给的纸型表,一眼就能看出值是否正确。如果w="11906",期望A4,说明单位没有问题;如果看到一个很小的数比如“210”,那一定是你把毫米直接填进去了。
我自己的习惯是:代码里全部用mmToTwips(),不直接写数字。这样即使后面调整纸型,只要改mm值,可读性和维护性都好很多。
5.2 新文档没有sectPr:空指针和“设置了但没效果”
症状一:sectPr.getPgSz()直接抛空指针。 症状二:明明调用了setW/setH,打开Word一看还是默认Letter大小。
这两个现象都指向同一个根源:文档里根本没有sectPr节点,或者有sectPr但你的修改没有作用到正确位置。对于new XWPFDocument()创建的空文档,大多数情况下body里没有现成的sectPr,必须先addNewSectPr()。
更隐蔽的是第二种:你的代码是这么写的:
CTBody body = doc.getDocument().getBody(); body.addNewSectPr().addNewPgSz().setW(...);然后后续又调用body.addNewP()新增段落。这时POI把新段落追加到了body子元素列表的末尾,可能被放在了sectPr之后。OOXML规范要求sectPr必须是body的最后一个子元素,一旦顺序不对,Word打开时可能忽略你的页面设置,甚至提示文档损坏。
稳妥做法是:先创建好所有段落、表格内容,最后再去设置sectPr;或者每次设置完页面后不要再往body里追加元素。如果实在需要追加内容,就重新获取body.getSectPr(),再将sectPr元素移到末尾。
5.3 多节文档:改了最后一节,前面的没变
一个文档有多个节时,body.getSectPr()只代表最后一个节。前面的节,其sectPr存在于各节最后一个段落的pPr里。
比如一份方案,封面页是A4纵向,正文要A3横向,这就是典型的多节设置。处理办法是遍历所有段落,找到带sectPr的段落:
for (XWPFParagraph paragraph : doc.getParagraphs()) { if (paragraph.getCTP().getPPr() != null && paragraph.getCTP().getPPr().isSetSectPr()) { CTSectPr paragraphSectPr = paragraph.getCTP().getPPr().getSectPr(); // 这里就是该段所在节的属性,可以修改 } }如果你只是想全局统一,还有一种简单方案:先把文档所有段落级sectPr删除,只保留body最后那个sectPr,这样全文就合并成单个节,再设置页面属性。注意这个操作会破坏原有的分节结构,比如分栏、独立页眉页脚都会失效,所以只在确定不需要分节时使用。
5.4 横向设置后内容方向不对:忘记交换宽高
前面3.2节已经强调过:横向必须宽高互换+orient为LANDSCAPE。这里再补一个容易忽略的点:如果模板已经存在pgSz,但你只setOrient(LANDSCAPE),没有设置宽高,那么Word打开后页面还是原来的长宽比。
排查技巧:直接在document.xml里检查w:pgSz的w和h。如果w=11906、h=16838,说明是A4纵向;如果w=16838、h=11906且orient=landscape,才是A4横向。任何方向异常,第一反应就是看这两个数字是不是互换状态。
6. 进阶:把页面设置封装成工具方法
6.1 通用页面设置工具
一个项目里处理多套模板、多类文档时,把页面设置封装成公共方法能省很多时间。下面这个工具类,支持传纸型、方向、边距,同时兼容“已有sectPr”和“无sectPr”的情况:
public class WordPageUtil { public static void setupPage(XWPFDocument doc, int widthMm, int heightMm, STPageOrientation orient, Double marginMm) { CTSectPr sectPr = doc.getDocument().getBody().getSectPr(); if (sectPr == null) { sectPr = doc.getDocument().getBody().addNewSectPr(); } CTPageSz pageSize = sectPr.isSetPgSz() ? sectPr.getPgSz() : sectPr.addNewPgSz(); boolean landscape = orient == STPageOrientation.LANDSCAPE; if (landscape) { pageSize.setW(mmToTwips(heightMm)); pageSize.setH(mmToTwips(widthMm)); } else { pageSize.setW(mmToTwips(widthMm)); pageSize.setH(mmToTwips(heightMm)); } pageSize.setOrient(orient); if (marginMm != null) { CTPageMar pageMar = sectPr.isSetPgMar() ? sectPr.getPgMar() : sectPr.addNewPgMar(); BigInteger margin = mmToTwips(marginMm); pageMar.setTop(margin); pageMar.setBottom(margin); pageMar.setLeft(margin); pageMar.setRight(margin); } } }注意横向的处理:我这里的参数widthMm、heightMm是“用户直观理解的宽高”。比如A4纸物理上是210mm宽、297mm高,如果横着用,调用方传入宽297、高210,内部再做一次交换,确保最终pgSz里的w永远是当前方向下的宽度。这个设计更贴近业务人员的理解方式。如果你习惯用“纸张物理宽高”,那就别做交换,直接传A4的210和297,内部再处理方向,方案不同,但思路一致。重点是不管怎么封装,最终落到pgSz的w和h必须是“当前方向下的宽高”。
6.2 页面设置和其他POI操作的组合顺序
除了页面设置,POI处理Word时还会频繁遇到:写标题、插表格、合并单元格、设置字体、插入图片。页面设置应该在写内容之前还是之后做?
我的建议是:先设置页面,再填充内容。原因很实际——表格宽度、页眉内容高度都依赖可用页面宽度。比如你在A4纵向、左右边距各2cm的前提下,想放一个占满可用宽度的表格,那么表格总宽是21cm - 4cm = 17cm。你在代码里把17cm换算成twips后设置到表格单元格。如果页面设置放在填充内容之后,表格宽度就得事后矫正,很麻烦。
实际组合示例:
// 1. 先建文档并设置A4纵向、边距2cm XWPFDocument doc = new XWPFDocument(); WordPageUtil.setupPage(doc, 210, 297, STPageOrientation.PORTRAIT, 20.0); // 2. 再插入一个宽度为17cm的表格 double usableWidthMm = 210 - 20 - 20; // 170mm XWPFTable table = doc.createTable(3, 3); // 设置表格总宽度 table.setWidth("" + mmToTwips(usableWidthMm)); table.setTableAlignment(TableRowAlign.CENTER);这里用setTableAlignment让表格居中,看起来更协调。类似地,页眉高度也会影响正文可用区域,但普通业务文档很少动它,知道有header这个字段就行。
还有一个小经验:如果同一个报告要给不同客户用不同纸型,不要把纸型和边距写死在业务代码里,而是做成配置项或字典表,修改时不需要改动程序。我在一个打印项目中就是维护了一张paper_config配置,字段包括paper_code、width_mm、height_mm、orientation、margin_top等,模板生成时动态读取,一劳永逸。
就我个人的使用习惯来说,POI操作Word页面设置的核心不在代码量,而在对“节”“twips单位”“多节归属”这三个点的理解。第一次做的时候我也踩过单位换算的坑,后来把mmToTwips和twipsToMm两个方法写进公共类之后,几乎再没出过问题。最后再分享一个实用小技巧:生成完docx后,不要只在Word里看效果,顺手用压缩软件打开word/document.xml,检查pgSz和pgMar的数值。这个动作对排查“奇怪样式”非常快,能省掉不少反复打开Word确认的时间。如果你也正在用POI 5.2.2操作Word的纸张和边距,建议直接把文中的工具方法抄进项目,再按自己业务调一下参数,基本就能覆盖80%的页面设置需求。