本文实现了基于坐标位置对PDF内容的底层修改而非覆盖,因此不会出现在某些高级PDF编辑器中可以移除插入内容或者文件随着编辑次数增多而大幅增大(原因是原内容还在文件中)的问题,而且使用的pdfbox是一个开源的、免费的PDF处理库,不需要开源自己的代码或者付费
一、操作原理
1、内容流
PDF文档的核心是其“内容流”(Content Stream),这是PDF页面上的所有对象和指令的集合。内容流可以看作一系列指令,这些指令告诉PDF阅读器如何绘制页面上的图形、文本、路径等元素。通过解析和修改这些内容流,能够精准地控制页面上的布局和内容。
1.1、查看——qpdf工具使用
`qpdf`是一款开源的PDF处理工具,它能够将PDF文件转换成易于人类阅读的格式,并且展示内容流。可以在解压后的qpdf bin目录下使用`qpdf --qdf input.pdf output.qdf`将PDF文件解析为QDF格式,使用文本编辑器打开output.qdf,这样便可以查看其底层内容结构,包括内容流的详细指令。可以通过在代码中设置特殊数值的指令去确定编辑文本内容的指令位置,比如设置 0.0 0.0 1.0 rg(含义见后续 操作符理解)
结果类似:
下载地址:https://github.com/qpdf/qpdf/releases
1.2、理解——操作符理解
在内容流中,PDF使用了操作符来定义绘制图形、文本及其他内容的行为,理解这些操作符的功能及其顺序是编辑内容流的关键。在操作流中,每个操作符都有自己的作用,比如文本操作符、图形和页面设置相关操作符、其他操作符等,并且它们通常结合使用来实现复杂的排版效果,需要注意,操作符直接是存在顺序的,不同的顺序可能会导致不可预料的不一样的效果,甚至可能无法生效,因此在查看pdf底层内容流结构时需要注意各操作符之间的顺序,在自己的代码中插入对应操作符时也应当按照相同的顺序插入。
常见的文本操作符
1. `BT` 和 `ET` (Begin Text/End Text)
- `BT`:表示开始一个新的文本对象。文本的所有绘制操作都应位于`BT`和`ET`之间。所有的文本操作(例如字体设置、位置移动等)都需要在这个范围内进行。
- `ET`:表示结束当前的文本对象,之后的操作将不再影响文本。
2. `Tf` (Text Font and Size)
- 用于设置文本字体和字体大小。语法为:`/FontName FontSize Tf`。
- `FontName`:字体名称,通常是PDF中定义的字体资源。
- `FontSize`:字体大小。
例如,`/F1 12 Tf` 表示将当前的字体设置为`F1`,大小为12。
3. `Tm` (Text Matrix)
- 用于设置文本矩阵,指定文本的缩放、旋转、位移等变换操作。文本矩阵用于确定文本块的变换效果,包括其位置和方向。语法为:`a b c d e f Tm`。
- `a`、`b`:水平方向缩放和旋转。
- `c`、`d`:垂直方向缩放和旋转。
- `e`、`f`:表示文本对象的X、Y位置(坐标)。
4. `Tj` (Show Text)
- 显示单行文本。语法为:`(text) Tj`,其中`text`表示要显示的字符串。(text) Tj表示直接显示ASCII或Unicode文本,文本内容以标准字符编码的形式写在括号中,<381a3e7c1afd058c02c8> Tj 表示文本的十六进制编码。PDF支持通过这种方式将字符串编码为字节流,然后通过设置的字体字形表来解释这些字节并将其显示为文本
例如:`(Hello, PDF!) Tj` 会在当前坐标处显示“Hello, PDF!”。
5. `TJ` (Show Text with Individual Positioning)
- 与`Tj`类似,但允许为每个字符或字符组设置不同的间距,通常用于调整字符的精确位置。语法为:`[array] TJ`,其中`array`是字符串和调整量的组合。
例如:`[(Hello) 120 (PDF)] TJ`,表示在“Hello”后移动120单位,然后显示“PDF”。
6. `Td` 和 `TD` (Move Text Position)
- `Td`:用于在当前文本位置基础上,移动新的位置,语法为:`x y Td`,其中`x`和`y`是移动的水平和垂直距离。
例如:`10 20 Td`表示将文本的位置向右移动10个单位,向上移动20个单位。
- `TD`:与`Td`相同,但它同时会将文本的行间距重置为新的垂直位移。
7. `Tm` (Set Text Matrix)
- 这个操作符直接设置文本的矩阵。它提供了精确的位置和变换控制。通常和`Td`一起使用,它指定文本块的位置和缩放。
8. `Tr` (Text Rendering Mode)
- 用于设置文本的渲染模式,语法为:`n Tr`。
- `n`的取值有:
- `0`:仅填充文本。
- `1`:仅描边文本。
- `2`:同时填充和描边文本。
- `3`:文本不渲染(但保留空间)。
9. `Tw` (Word Spacing)
- 设置词间距,语法为:`w Tw`。将指定的间距应用到文本对象中的空格字符。
例如:`10 Tw`表示设置单词之间的间距为10个单位。
10. `Tc` (Character Spacing)
- 设置字符之间的间距,语法为:`c Tc`。它为文本对象中的每个字符之间增加固定的间距。
11. `T*` (Move to Next Line)
- 将文本位置移动到下一行,使用的是当前的行间距和文本矩阵。通常用于实现多行文本。
12. `rg` 和 `RG` (Set RGB Color for Filling/Stroking)
- `rg`:设置填充操作的RGB颜色,语法为:`r g b rg`,其中`r`、`g`、`b`的取值范围是`0`到`1`。
- `RG`:设置描边操作的RGB颜色,语法与`rg`相同。
13. g
和 G
(Set Gray Color for Filling/Stroking)
- `g
`:设置灰度填充颜色,语法为:gray g
,其中gray
的值在0
到1
之间,0
表示黑色,1
表示白色。
- `G
`:设置灰度描边颜色,语法与g
相同
14. w
(Set Line Width)
- 设置描边线条的宽度。语法为:lineWidth w
,例如2 w
表示设置线宽为2个单位。
1.3、扩展——操作后word转pdf
当我们将Word文件转换成PDF时,Word中的文本和图形也会被转换成PDF的内容流。通过对比转换前后的内容,我们可以进一步理解这些操作符的使用。Word文件通常包含较多的复杂格式化信息,这些会被转换成复杂的内容流指令,例如嵌入字体、图像、文本排版等。因此假如要实现某个操作效果,比如加粗或者斜体,则可以先在word中进行操作,然后使用qpdf转为可理解的qdf格式,查看加粗或斜体对应底层内容流的操作符是什么,是如何设值的,进而在自己的代码中照例插入
比如某个坐标的文本内容为斜体,在word中设置后,转换为qdf查看对比可见,1 0 0.3333 1 111.14 742.66 Tm 其中 0.3333 可以理解为倾斜度的一个数值表示(Tm的各项矩阵数值含义见上述 操作符理解)
1.4、验证——修改qdf文件查看
通过`qpdf`将PDF转换为QDF格式后,可以手动修改其内容流,例如删除某些操作符、添加新的指令,保存后再用PDF查看器打开以验证修改是否生效。这种实时的修改和验证有助于深入理解内容流的结构和PDF的解析逻辑。
2、处理内容流
2.1、构造内容流和坐标的对应
(重写PDFTextStripper中的部分方法后与原内容流进行比对处理)
PDFBox中的`PDFTextStripper`类负责提取PDF中的文本内容。通过继承PDFTextStripper重写其中的processOperator(处理操作符)、processTextPosition(处理文本信息,它会在processOperator方法后自动调用)方法,可以获取操作符下标以及每个文本字符的坐标信息,并将其与原始内容流中的纯操作符进行顺序比对和代码处理,即可得到原始内容流和文本坐标的集合,具体可见后续操作代码
2.2、坐标匹配获取操作符下标
为了编辑PDF中的文本,我们首先需要定位文本在内容流中的位置。通过将输入的坐标位置与PDF内容流中的文本字符坐标进行匹配,可以精确定位到输入坐标在内容流中的位置。
2.3、对匹配的内容进行修改重组
内容的修改与重组是基于操作符的解析和插入进行的,区分普通文本内容(Tj)和带有字符间距的文本数组(TJ)内容。
2.3.1、删除原匹配文本内容
一旦找到了需要修改的文本块,首先是从内容流中删除原来的文本。可以通过操作符的索引直接将其从内容流中移除。
2.3.2、保持前缀内容重新插入
在删除原文本后,我们通常需要保留匹配坐标前面的文本内容和一些布局信息(例如坐标、字体设置等),这些前缀内容不应被改变,它可以维持前缀的文本内容的布局信息。
2.3.3、自定义新内容插入
在插入新文本时,需要考虑到字体的设置。如果PDF中的字体没有包含新文本所需的字符,可能会导致显示问题,因此通常选择使用系统字体来确保文本正确显示。插入时需要根据坐标和字体等自定义的参数来绘制新的文本块。
2.3.4、后缀内容状态回退插入
由于PDF是通过操作符逐步构建页面的,因此在插入新内容后文本状态可能会发送改变,必须恢复之前的状态再插入原本的后缀内容。这个过程需要回退到匹配文本位置之前的状态,例如字体、坐标、颜色等,然后按重新插入后续内容。
二、使用PDFbox进行操作
1、引入pdfbox依赖
<dependency><groupId>org.apache.pdfbox</groupId><artifactId>pdfbox</artifactId><version>2.0.16</version></dependency>
2、重写PDFTextStripper中的方法
import org.apache.pdfbox.contentstream.operator.Operator;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.pdfbox.text.TextPosition;import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;public class TokenIndexPDFTextStripper extends PDFTextStripper {// 存储每个操作符和参数的下标信息private List<Object> tokenList = new ArrayList<>();public TokenIndexPDFTextStripper() throws IOException {super();}@Overrideprotected void processOperator(Operator operator, List<COSBase> arguments) throws IOException {// 遍历并记录参数for (COSBase argument : arguments) {tokenList.add(argument);}// 记录操作符下标tokenList.add(operator);// 调用父类的方法以确保文本提取继续正常进行super.processOperator(operator, arguments);}// 扩展方法,获取文本坐标@Overrideprotected void processTextPosition(TextPosition text) {HashMap<String, Object> textPosition = new HashMap<>();textPosition.put("text",text.getUnicode());// 字符的左下角位置坐标// 从左往右的水平位移textPosition.put("x",text.getXDirAdj());// 从上往下的垂直位移textPosition.put("y",text.getYDirAdj());textPosition.put("width",text.getWidthDirAdj());textPosition.put("height",text.getHeightDir());textPosition.put("pageHeight",text.getPageHeight());textPosition.put("pageWidth",text.getPageWidth());// 将文本位置信息添加到 tokenList 中tokenList.add(textPosition);System.out.print("\033[31m"+text.getUnicode()+" \033[0m"+text.getXDirAdj()+","+text.getYDirAdj()+" ");super.processTextPosition(text);}public List<Object> getTokenList() {return tokenList;}
}
3、处理pdf的主方法
/*** PDF编辑VO*/
public class PDFEditVO {/*** x坐标*/private Float cursorX;/*** y坐标*/private Float cursorY;/*** 需要插入的文本*/private String insertText;/*** 需要删除的字符数*/private Integer deleteNum;/*** PDF文件路径*/private String path;/*** 字体名称*/private String fontName;/*** 字号大小*/private Integer fontSize;/*** 字体颜色,比如蓝色为0,0,1*/private List<Float> color;/*** 线宽,一般加粗为 1.0*/private Float lineWidth;/*** 文本绘制模式(0 填充模式/默认模式;1 描边模式/文本空心描边;2 填充并描边模式;3 不填充不描边模式/不可见;。。。)*/private String textRenderingMode;/*** 倾斜率,一般斜体为0.3333*/private Float tiltRatio;public Float getCursorX() {return cursorX;}public Float getCursorY() {return cursorY;}public String getInsertText() {return insertText;}public Integer getDeleteNum() {return deleteNum;}public String getPath() {return path;}public String getFontName() {return fontName;}public Integer getFontSize() {return fontSize;}public List<Float> getColor() {return color;}public Float getLineWidth() {return lineWidth;}public String getTextRenderingMode() {return textRenderingMode;}public Float getTiltRatio() {return tiltRatio;}
}
public void editPDF(PDFEditVO editVO) {String path = editVO.getPath();List<Float> colors = editVO.getColor();String outputFilePath = "output.pdf";Float cursorX = editVO.getCursorX();Float cursorY = editVO.getCursorY();Integer deleteNum = editVO.getDeleteNum();String insertText = editVO.getInsertText();String fontName = editVO.getFontName();Integer fontSize = editVO.getFontSize();Float lineWidth = editVO.getLineWidth();String textRenderingMode = editVO.getTextRenderingMode();Float tiltRatio = editVO.getTiltRatio();try {PDDocument document = PDDocument.load(new File(path));Font[] allFonts = GraphicsEnvironment.getLocalGraphicsEnvironment().getAllFonts();PDPageTree pages = document.getPages();for (int pageItem = 0; pageItem < pages.getCount(); pageItem++) {PDPage page = pages.get(pageItem);// 获取页面的内容流集合PDFStreamParser parser = new PDFStreamParser(page);parser.parse();List<Object> tokens = parser.getTokens();// 获取带有文本位置的内容流集合TokenIndexPDFTextStripper stripper = new TokenIndexPDFTextStripper();stripper.setSortByPosition(true); // 按位置排序提取文本stripper.setStartPage(pageItem + 1); // 设置开始页stripper.setEndPage(pageItem + 1); // 设置结束页stripper.getText(document); // 提取文本并记录每个操作符的下标List<Object> tokenList = stripper.getTokenList();// 过滤多余操作符的处理(PDFTextStripper会将某些操作符进行转换处理,比如0 -11.664 TD-》11.664 TL 0 -11.664 Td,需要将这些多余内容过滤但同时保留文本位置信息)List<List<Object>> tokens1 = new ArrayList<>();int index = 0;for (Object object : tokenList) {if (object instanceof HashMap) {tokens1.add(Arrays.asList(object, index - 1));} else {if (object.toString().equals(tokens.get(index).toString())) {tokens1.add(Arrays.asList(tokens.get(index), index));index++;}}}// 根据接口参数确定光标位置对应的tokens下标int tokenIndex = 0;Float insertCharX = null;Float insertCharY = null;StringBuffer preString = new StringBuffer();StringBuffer suffixString = new StringBuffer();for (int i = 0; i < tokens1.size(); i++) {List<Object> objects = tokens1.get(i);if (objects.get(0) instanceof HashMap) {HashMap<String, Object> hashMap = (HashMap<String, Object>)objects.get(0);Float x = (Float)hashMap.get("x");Float y = (Float)hashMap.get("y");Float width = (Float)hashMap.get("width");Float height = (Float)hashMap.get("height");if ((cursorX >= x && cursorX <= (x + width)) && (cursorY >= y && cursorY <= (y + height))) {tokenIndex = (int)objects.get(1);for (int j = 0; j < tokens1.size(); j++) {List<Object> objects1 = tokens1.get(j);// 以存储的tokens下标作为匹配条件int storageIndex = (int)objects1.get(1);if (storageIndex == tokenIndex){if (objects1.get(0) instanceof HashMap) {HashMap<String, Object> hashMap1 = (HashMap<String, Object>)objects1.get(0);String text1 = String.valueOf(hashMap1.get("text"));Float x1 = (Float)hashMap1.get("x");Float y1 = (Float)hashMap1.get("y");Float width1 = (Float)hashMap1.get("width");Float pageHeight1 = (Float)hashMap1.get("pageHeight");if (Objects.isNull(insertCharX)||Objects.isNull(insertCharY)){insertCharX = x1;insertCharY = pageHeight1- y1;}// 不拼接指定匹配坐标位置往前,deleteNum个数的字符if (j <= i - deleteNum) {preString.append(text1);insertCharX += width1;}else if (j > i){suffixString.append(text1);}}}}break;}}}if (tokenIndex == 0) {System.out.println("cant find cursorX, cursorY");return;}// 根据tokens下标,对内容流进行处理PDResources resources = page.getResources();COSName sysFontName = getSysFontName(document, allFonts, resources, fontName);Object indexToken = tokens.get(tokenIndex);if (indexToken instanceof Operator) {Operator op = (Operator)indexToken;String opName = op.getName();if (OperatorName.SHOW_TEXT.equals(opName)) {// 记录当前匹配的Tj之上到BT的内容(既用于设置新的文本绘制块,也用于设置状态回退操作符)List<List<Object>> ascPreTokensAll = getPreTokensAll(tokens, tokenIndex);List<List<Object>> descPreTokensAll = new ArrayList<>(ascPreTokensAll);Collections.reverse(descPreTokensAll);Optional<List<Object>> first = descPreTokensAll.stream().filter(item -> item.stream().anyMatch(item1 -> item1 instanceof Operator && OperatorName.SET_FONT_AND_SIZE.equals(((Operator)item1).getName()))).findFirst();if (first.isPresent()) {List<Object> objects = first.get();COSName currentFontName = (COSName)objects.get(0);PDFont currentFont = resources.getFont(currentFontName);if (StringUtils.hasText(insertText)) {tokens.remove(tokenIndex);tokens.remove(tokenIndex-1);int newNum = tokenIndex-1;// 插入原本的前缀文本绘制块if (StringUtils.hasText(preString)) {byte[] encode = currentFont.encode(preString.toString());tokens.add(newNum++, new COSString(encode));tokens.add(newNum++, Operator.getOperator(OperatorName.SHOW_TEXT));}// 插入新插入的文本绘制块// w 设置线宽tokens.add(newNum++, new COSFloat(lineWidth)); // 线宽tokens.add(newNum++, Operator.getOperator(OperatorName.SET_LINE_WIDTH));// Tf 设置字体和字号tokens.add(newNum++, sysFontName); // 字体名称设置,需要采用系统字体,否则可能字形不存在tokens.add(newNum++, new COSFloat(fontSize)); // 字号tokens.add(newNum++, Operator.getOperator(OperatorName.SET_FONT_AND_SIZE));tokens.add(newNum++, COSNumber.get(textRenderingMode)); // 文本绘制模式tokens.add(newNum++, Operator.getOperator(OperatorName.SET_TEXT_RENDERINGMODE));// Tm 设置文本变换矩阵,用于控制文本缩放倾斜,位移属性// 定义矩阵的参数tokens.add(newNum++, new COSFloat(1)); // 水平缩放因子,表示文本在 X 方向不缩放tokens.add(newNum++, new COSFloat(0)); // 表示文本在 X 方向没有旋转或倾斜tokens.add(newNum++, new COSFloat(tiltRatio)); // 垂直方向的倾斜因子,表示文本在 Y 方向倾斜,产生一个水平扭曲的效果(斜体效果)tokens.add(newNum++, new COSFloat(1)); // 垂直缩放因子,表示文本在 Y 方向不缩放tokens.add(newNum++, new COSFloat(insertCharX)); // X 方向的位置tokens.add(newNum++, new COSFloat(insertCharY)); // Y 方向的位置tokens.add(newNum++, Operator.getOperator(OperatorName.SET_MATRIX));// RG 设置描边颜色tokens.add(newNum++, new COSFloat(colors.get(0))); // 红色tokens.add(newNum++, new COSFloat(colors.get(1))); // 绿色tokens.add(newNum++, new COSFloat(colors.get(2))); // 蓝色tokens.add(newNum++, Operator.getOperator(OperatorName.STROKING_COLOR_RGB));// rg 设置填充颜色tokens.add(newNum++, new COSFloat(colors.get(0))); // 红色tokens.add(newNum++, new COSFloat(colors.get(1))); // 绿色tokens.add(newNum++, new COSFloat(colors.get(2))); // 蓝色tokens.add(newNum++, Operator.getOperator(OperatorName.NON_STROKING_RGB));// Tj 显示文本PDFont sysFont = resources.getFont(sysFontName);tokens.add(newNum++,new COSString(sysFont.encode(insertText))); // 添加新的文本内容tokens.add(newNum++, Operator.getOperator(OperatorName.SHOW_TEXT));// 插入原本的后缀文本绘制块if (StringUtils.hasText(suffixString)) {// 手动状态回退设值tokens.add(newNum++, new COSFloat(0));tokens.add(newNum++, Operator.getOperator(OperatorName.NON_STROKING_GRAY));tokens.add(newNum++, new COSFloat(0));tokens.add(newNum++, Operator.getOperator(OperatorName.STROKING_COLOR_GRAY));tokens.add(newNum++, COSNumber.get(String.valueOf(RenderingMode.FILL.intValue())));tokens.add(newNum++, Operator.getOperator(OperatorName.SET_TEXT_RENDERINGMODE));tokens.add(newNum++, new COSFloat(1));tokens.add(newNum++, new COSFloat(0));tokens.add(newNum++, new COSFloat(0));tokens.add(newNum++, new COSFloat(1));// 获取文本的宽度(单位是字体设计单位的一部分,需要按比例缩放)float stringWidth = sysFont.getStringWidth(insertText);// 计算文本的实际宽度float textWidth = stringWidth * fontSize / 1000;tokens.add(newNum++, new COSFloat(insertCharX + textWidth));tokens.add(newNum++, new COSFloat(insertCharY));tokens.add(newNum++, Operator.getOperator(OperatorName.SET_MATRIX));// 自动状态回退设值(将BT到该匹配坐标之前的字体颜色样式大小等按顺序设置一遍,保证重新回归字体状态)List<String> backStates = Arrays.asList(OperatorName.SET_FONT_AND_SIZE, OperatorName.NON_STROKING_RGB,OperatorName.NON_STROKING_GRAY, OperatorName.STROKING_COLOR_RGB,OperatorName.STROKING_COLOR_GRAY,OperatorName.SET_LINE_WIDTH,OperatorName.SET_TEXT_RENDERINGMODE);List<List<Object>> backTokens = ascPreTokensAll.stream().filter(item -> item.stream().anyMatch(item1 -> item1 instanceof Operator && backStates.contains(((Operator)item1).getName()))).collect(Collectors.toList());for (List<Object> item : backTokens) {for (Object item1 : item) {tokens.add(newNum++, item1);}}// 插入原本的后缀文本内容byte[] encode = currentFont.encode(suffixString.toString());tokens.add(newNum++, new COSString(encode));tokens.add(newNum++, Operator.getOperator(OperatorName.SHOW_TEXT));}} else {byte[] encode = currentFont.encode(preString.toString() + suffixString.toString());tokens.remove(tokenIndex-1);tokens.add(tokenIndex-1,new COSString(encode));}}}else if (OperatorName.SHOW_TEXT_ADJUSTED.equals(opName)){// TODO 处理文本数组(包含间距元素的多段文本数组)时,同理将前缀内容继续保持为文本数组,插入内容重新设置,恢复文本状态后将后缀内容继续保持为文本数组System.out.println("进入文本数组处理");}}PDStream newContentStream = new PDStream(document);try (java.io.OutputStream out = newContentStream.createOutputStream()) {ContentStreamWriter writer = new ContentStreamWriter(out);writer.writeTokens(tokens);}// 将新的内容流设置回页面page.setContents(newContentStream);}document.save(outputFilePath);document.close();System.out.println("操作完成");} catch (Exception e) {e.printStackTrace();}}/*** 获取当前匹配之前至BT的内容流* @param tokens 内容流* @param i 当前匹配的下标* @return 当前匹配之前至BT的内容流*/private List<List<Object>> getPreTokensAll(List<Object> tokens, int i) {List<Integer> preTokenIndex = new ArrayList<>();for (int j = i-1; j > 0; j--) {Object token1 = tokens.get(j);if (token1 instanceof Operator) {Operator op1 = (Operator)token1;String opName1 = op1.getName();if (OperatorName.BEGIN_TEXT.equals(opName1)) {break;}}preTokenIndex.add(j);}Collections.sort(preTokenIndex);List<Object> orgPreTextTokens =preTokenIndex.stream().map(tokens::get).collect(Collectors.toList());// 重组原始文本绘制块的内容List<List<Object>> preTokensAll = new ArrayList<>();List<Object> singleToken = new ArrayList<>();for (Object item : orgPreTextTokens) {singleToken.add(item);if (item instanceof Operator) {preTokensAll.add(singleToken);singleToken = new ArrayList<>();}}return preTokensAll;}/*** 根据当前匹配字体获取对应系统字体* @param document PDF文档对象* @param allFonts 系统所有字体* @param resources 页面资源* @param fontName 输入的字体名称* @return 当前匹配字体获取对应系统字体在页面资源加载后的名称*/private COSName getSysFontName(PDDocument document, Font[] allFonts, PDResources resources, String fontName) {COSName sysFontName = null;for (Font fon : allFonts) {try {// 通过反射获取 Font2D 对象Font2D font2D = FontUtilities.getFont2D(fon);// 通过反射访问 Font2D 的 familyName 字段Field familyNameField = Font2D.class.getDeclaredField("familyName");familyNameField.setAccessible(true);String familyName = (String)familyNameField.get(font2D);// 获取字体文件路径if (font2D instanceof sun.font.PhysicalFont) {Field platNameField =sun.font.PhysicalFont.class.getDeclaredField("platName");platNameField.setAccessible(true);String platName = (String)platNameField.get(font2D);if (fontName.toLowerCase(Locale.ROOT).contains(familyName.toLowerCase(Locale.ROOT))) {PDType0Font sysFont;if (platName.toLowerCase(Locale.ROOT).endsWith(".ttc")) {// 加载TTC文件org.apache.fontbox.ttf.TrueTypeCollection ttc =new org.apache.fontbox.ttf.TrueTypeCollection(new File(platName));// 选择其中一个字体List<TrueTypeFont> trueTypeFonts = new ArrayList<>();ttc.processAllFonts(trueTypeFonts::add);sysFont = PDType0Font.load(document, trueTypeFonts.get(0), true);} else {sysFont = PDType0Font.load(document, new File(platName));}sysFontName = resources.add(sysFont);}}} catch (Exception e) {e.printStackTrace();}}return sysFontName;}