You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Java下修改PDF模板生成PDF的最优方案及DOCX转PDF问题咨询

问题背景与技术咨询

需要向前端返回20+页的PDF文件,其中大部分内容为静态,仅2页的部分文本需根据日期、姓名、编号等参数动态修改。因此倾向于基于模板修改而非从头生成PDF,目前考虑两种方案,存在以下疑问:

  1. 直接修改PDF模板:了解到只能用白色矩形覆盖原有文本再新增内容,会导致用户选中文本异常、文本宽度无法自适应,这个认知是否正确?
  2. 使用DOCX模板:通过Apache POI修改内容后转PDF,但使用fr.opensagres.xdocreport库转换时出现水印丢失、列表编号异常、文本重叠等问题,求更精准的转换库,或是否存在更优的混合方案?

以下是当前实现的Java服务方法代码及build.gradle配置,寻求相关实践经验与建议。

当前Java服务实现代码

@Override
public byte[] getContract() {
    byte[] pdfContent;
    String docxPath = "doc/contract.docx";
    InputStream in = Thread.currentThread().getContextClassLoader().getResourceAsStream(docxPath);

    try (XWPFDocument doc = new XWPFDocument(Objects.requireNonNull(in))) {
        for (XWPFParagraph par : doc.getParagraphs()) {
            List<XWPFRun> runs = par.getRuns();
            if (!CollectionUtils.isEmpty(runs)) {
                for (XWPFRun run : runs) {
                    String text = run.getText(0);
                    if (!StringUtils.isEmpty(text)) {
                        if (text.contains(PH_DAY)) {
                            text = text.replace(PH_DAY, "23");
                        }
                        if (text.contains(PH_MONTH)) {
                            text = text.replace(PH_MONTH, "Fevral");
                        }
                        if (text.contains(PH_YEAR)) {
                            text = text.replace(PH_YEAR, "2023");
                        }
                        if (text.contains(PH_ORDINAL_SUFFIX)) {
                            text = text.replace(PH_ORDINAL_SUFFIX, "cü");
                        }
                        if (text.contains(PH_PIN)) {
                            text = text.replace(PH_PIN, "№ AF233031");
                        }
                        if (text.contains(PH_NAME)) {
                            text = text.replace(PH_NAME, "Surname Name");
                        }
                        run.setText(text, 0);
                    }
                }
            }
        }

        try (ByteArrayOutputStream docxOut = new ByteArrayOutputStream()) {
            doc.write(docxOut);
            try (ByteArrayOutputStream pdfOut = new ByteArrayOutputStream()) {
                PdfOptions pdfOptions = PdfOptions.create();
                PdfConverter.getInstance().convert(doc, pdfOut, pdfOptions);
                pdfContent = pdfOut.toByteArray();
            }
        }
    } catch (Exception e) {
        String message = e.getMessage();
        log.error(message);
        throw new GeneralTechnicalException(GeneralTechErrorEnum.SERVER_ERROR, message, e);
    }

    return pdfContent;
}

build.gradle配置

buildscript {
    repositories {
        mavenCentral()
    }
}

plugins {
    id 'org.springframework.boot' version "$springBootVersion"
    id 'io.spring.dependency-management' version "$springDependencyVersion"
    id 'java'
    id 'org.sonarqube' version "$sonarqubeVersion"
    id 'jacoco'
    id 'checkstyle'
    id 'pmd'
}

group = 'my.project'
version = '$projectVersion'
sourceCompatibility = '$sourceCompatibility'

repositories {
    mavenCentral()
    maven {
        url "${nexusUrl}/repository/maven-public/"
        allowInsecureProtocol = true
    }
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    implementation 'org.springframework.boot:spring-boot-starter-cache'
    implementation "org.springframework.cloud:spring-cloud-starter-openfeign:$openFeignVersion"
    implementation "com.oracle.ojdbc:ojdbc8:$oracleVersion"
    implementation "junit:junit:$junitVersion"
    implementation "org.redisson:redisson:$redissonVersion"
    implementation 'org.apache.commons:commons-lang3'
    implementation "org.springdoc:springdoc-openapi-ui:$springDocVersion"
    implementation "net.logstash.logback:logstash-logback-encoder:$logbackEncoderVersion"
    implementation 'ch.qos.logback:logback-classic'
    implementation "org.springframework.cloud:spring-cloud-dependencies:$releaseTrainVersion"
    implementation "com.ibam:ibam-error-handling:$ibamErrorHandlerVersion"
    implementation group: 'org.codehaus.jettison', name: 'jettison', version: "$jettisonVersion"
    implementation group: 'redis.clients', name: 'jedis', version: "${jedis}"
    implementation group: 'com.esotericsoftware', name: 'kryo', version: "${kryo}"
    implementation 'org.liquibase:liquibase-core'
//    implementation "com.github.librepdf:openpdf:$openpdfVersion"
    implementation "org.apache.poi:poi-ooxml:5.2.3"
    implementation "org.apache.logging.log4j:log4j-to-slf4j:2.20.0"
    implementation "org.apache.logging.log4j:log4j-api:2.20.0"
    implementation "fr.opensagres.xdocreport:fr.opensagres.poi.xwpf.converter.pdf:2.0.4"

    //  developmentOnly 'org.springframework.boot:spring-boot-devtools'

    compileOnly "org.projectlombok:lombok:$lombokVersion"

    annotationProcessor "org.projectlombok:lombok:$lombokVersion"
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'

    implementation "com.auth0:java-jwt:$jwtAuthVersion"

    testImplementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.11.4'
    testImplementation('org.springframework.boot:spring-boot-starter-test') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }
}

tasks.named('test') {
    useJUnitPlatform()
}

pmd {
    toolVersion = '6.10.0'
    ruleSets = []
    ignoreFailures = false
    ruleSetFiles = files("${rootProject.projectDir}/pmd/rules.xml")
}

checkstyle {
    toolVersion = "$checkstyleToolVersion"
    configFile = file("${rootProject.projectDir}/checkstyle/checkstyle.xml")
    ignoreFailures = false
}

tasks.withType(Checkstyle) {
    reports {
        xml.enabled true
        html.enabled true
    }
}

jacoco {
    toolVersion = "0.8.8"
    reportsDir = file("build/reports/jacoco")
}

jacocoTestReport {
    reports {
        xml.enabled true
        csv.enabled false
    }
}

sonarqube {
    properties {
        property "sonar.java.coveragePlugin", "jacoco"
        property 'sonar.coverage.exclusions', sonarExclude
        property "sonar.jacoco.reportPath", "build/jacoco/test.exec"
        property "sonar.junit.reportsPath", "build/test-results/test"
        property "sonar.scm.forceReloadAll", "true"
        property "sonar.java.checkstyle.reportPaths", "build/reports/checkstyle/main.xml"
    }
}

bootJar {
    archiveName 'app.jar'
}

实践经验与建议

关于直接修改PDF模板的认知验证

你的部分认知是对的,但并非只能用覆盖的方式:

  • 老旧的PDF修改方式确实常用白色矩形覆盖原有文本再叠加新内容,这会导致选中文本时出现原文本和新文本重叠的问题,且新文本无法自适应原有文本框宽度。
  • 更优的做法是使用支持PDF表单域的模板:提前在PDF模板中需要动态修改的位置添加文本表单域,后端通过PDF操作库(如iText、Apache PDFBox)直接填充表单域值。这种方式不会破坏原有文本结构,选中文本正常,且表单域可设置自动换行/自适应宽度,完全规避覆盖法的问题。

DOCX转PDF的替代方案

fr.opensagres.xdocreport对复杂DOCX格式的支持确实有限,推荐以下更可靠的转换库:

  1. Aspose.Words for Java:对DOCX格式的兼容性极强,能完美保留水印、列表、格式排版,转换精度很高。但它是商业库,需要授权。
  2. LibreOffice/OpenOffice Headless模式:通过调用外部Office程序的无头模式进行转换,支持所有Office格式,转换效果和手动保存PDF一致。需要在服务器部署LibreOffice,后端通过ProcessBuilder调用命令行执行转换。
  3. Apache POI + Flying Saucer:如果DOCX结构不复杂,可先将DOCX转换为HTML,再用Flying Saucer转PDF。但需要处理HTML样式还原的问题,适合格式简单的场景。

最优混合方案

结合你的场景(大部分静态,仅2页动态),推荐PDF模板+表单域填充的方案:

  1. 提前将整个合同导出为PDF模板,在需要动态修改的位置添加文本表单域(如日期、姓名、编号等)。
  2. 后端使用iText或PDFBox加载PDF模板,直接填充表单域的值,生成最终PDF。
    这种方案无需处理DOCX转PDF的格式丢失问题,性能更高,且输出的PDF完全符合预期,不会出现选中文本异常的情况。

内容的提问来源于stack exchange,提问作者Oleg Sandro

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.30 23:54:32