ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

实现 DWG 转 PDF/PNG/JPG 服务(强化版)

2026/10/5 8:21:16 拓冰建站 浏览量
实现 DWG 转 PDF/PNG/JPG 服务(强化版) 一、需求拆解说白了就三件事上传一个 DWG 文件接口返回 PDF / PNG / JPG打成 Docker 镜像Windows 和 Linux 上都能跑服务端口可配置技术栈定的 Java 17 Spring Boot 3。端口可配置这点 Spring Boot 本身就支持用 server.port 或者环境变量 SERVER_PORT 都行后面会讲怎么跟 Docker 的端口映射串起来。二、选型纯 Java 这条路走不通一开始我想得挺简单找个 Java 库解析 DWG 不就完了。结果查了一圈发现不是这么回事。DWG 是 Autodesk 的私有二进制格式官方从来没公开过完整规范Java 生态里确实没有能打的解析库Kabeja 算是比较有名的 DXF 库但最后一次更新是 2008 年而且根本没发布到 Maven Central想用还得自己打 jarjdxf 之类的库更小众也基本停更了顺带说一句Kabeja 只能读 DXFDWG 它读不了。所以路线只能改成Spring Boot 负责服务化CAD 的解析和渲染交给成熟的开源引擎。这也算业内通行做法了。最后定下来的流水线是这样图 1 整体转换流水线DWG → DXF用 LibreDWG 的 dwg2dxf 命令。LibreDWG 是 GNU 项目GPLv3官方说覆盖约 90% 的 DWG 实体DXF → 图片用 Python 的 ezdxf matplotlib这是目前最靠谱的开源 DXF 渲染方案R12 到 R2018 都支持SVG → PNG/JPGApache Batik纯 Java用来按任意 DPI 重新光栅化可能有同学会问都 Spring Boot 了怎么还塞 Python 进来。实话说我也不想但 Java 侧确实没有能替代 ezdxf 的方案。而且 Python 那边我写得非常薄只干「DXF 出图」这一件事接口、参数校验、临时目录、超时这些全在 Java 里。三、代码框架工程结构大概是这样核心类没几个各管一摊DWG转pdf-png-jpg-Java/├── pom.xml Spring Boot 3.3.5 / Java 17├── Dockerfile 三阶段构建├── docker-compose.yml├── scripts/│ └── dxf2img.py DXF → SVG/PDF/PNG/JPG 渲染器└── src/main/java/com/example/dwgconverter/├── DwgConverterApplication.java├── config/ConverterProperties.java 外部化配置├── controller/ConversionController.java REST 接口├── controller/GlobalExceptionHandler.java├── model/{OutputFormat,ConversionOptions,ConversionResult}.java└── service/├── ConversionService.java 接口├── DwgConversionService.java 主流水线├── ProcessRunner.java 带超时的外部命令执行器├── SvgRasterizer.java Batik 纯 Java 光栅化└── ToolchainInspector.java 工具链自检对外提供的接口方法路径说明GET/api/v1/health工具链自检dwg2dxf / ezdxf 是否就绪GET/api/v1/formats支持的输入输出格式POST/api/v1/convert上传 DWG/DXF返回单个 PDF/PNG/JPG/SVGPOST/api/v1/convert/batch一次生成多格式打包 ZIPPOST/api/v1/convert/base64多格式返回 JSON Base64POST/api/v1/svg-to-imageSVG 转 PNG/JPG纯 JavaController 这块没什么花活重点是把异常统一收口。所有转换失败都抛 ConversionException由 GlobalExceptionHandler 转成结构化 JSON前端好处理ExceptionHandler(UnsupportedFormatException.class)public ResponseEntityMapString, Object handleUnsupportedFormat(UnsupportedFormatException e, HttpServletRequest request) {return build(HttpStatus.BAD_REQUEST, e.getMessage(), request);}ExceptionHandler(ConversionException.class)public ResponseEntityMapString, Object handleConversion(ConversionException e, HttpServletRequest request) {log.warn(转换失败: {}, e.getMessage());return build(HttpStatus.UNPROCESSABLE_ENTITY, e.getMessage(), request);}四、几个设计上的取舍4.1 用进程调用不碰 JNILibreDWG 有 C 接口理论上能用 JNI 或 JNA 直接调但我选了最土的 ProcessBuilder。原因很简单JNI 一旦涉及跨平台编译、崩溃隔离这些事就很麻烦。进程调用崩了顶多这个请求失败不会把整个 JVM 带走而且换实现特别容易 —— 想从 LibreDWG 换成 ODA File Converter改个配置路径就行一行 Java 代码都不用动。4.2 每次转换一个独立临时目录每个请求开一个临时目录转换完递归删掉避免并发请求互相踩。调试的时候可以设 DWG_KEEP_TEMP_FILEStrue 把中间文件留下来看。private Path createWorkDir() {Path base Path.of(properties.getWorkDir());Files.createDirectories(base);return Files.createTempDirectory(base, job-);}private void cleanup(Path workDir) {if (properties.isKeepTempFiles()) { log.info(已保留中间文件{}, workDir); return; }try (StreamPath paths Files.walk(workDir)) {paths.sorted(Comparator.reverseOrder()).forEach(p - {try { Files.deleteIfExists(p); } catch (IOException e) { log.warn(删除失败 {}, p); }});}}4.3 外部命令必须带超时这点我觉得挺重要。matplotlib 渲染大图纸很吃 CPU万一张图有几十万个实体进程卡死请求就跟着一起挂。超时直接 destroyForcibly()宁可这次转换失败也不能把线程池耗光boolean finished process.waitFor(timeoutSeconds, TimeUnit.SECONDS);if (!finished) {process.destroyForcibly();process.waitFor(5, TimeUnit.SECONDS);throw new ConversionException(外部命令执行超时 timeoutSeconds 秒);}4.4 成功判据看文件不看退出码这里有个小细节。dwg2dxf 遇到可恢复的告警也会返回非 0 退出码如果按退出码判断就会误判成失败。所以我改成以「DXF 文件到底有没有真的生成、大小是不是 0」作为判据非 0 但文件正常就打个 warn 放行ProcessRunner.CommandResult result processRunner.run(command, workDir, properties.getTimeoutSeconds());if (!Files.isRegularFile(dxf) || sizeOf(dxf) 0) {throw new ConversionException(DWG 转 DXF 失败dwg2dxf 退出码 result.exitCode() tail(result.output()));}if (!result.isSuccess()) {log.warn(dwg2dxf 退出码为 {}但已生成 DXF继续处理, result.exitCode());}五、Docker 部署镜像用多阶段构建编译工具链不进最终镜像否则一个 gcc 就把镜像撑到 1G 以上图 2 三阶段镜像构建# Stage 1编译 LibreDWG拿到 dwg2dxfFROM debian:bookworm-slim AS libredwgRUN sh ./autogen.sh ./configure --enable-release --disable-bindings \--disable-docs --disable-shared make -j$(nproc) make install# Stage 2构建 Spring Boot fat jarFROM maven:3.9.9-eclipse-temurin-17 AS buildRUN mvn -B -DskipTests package# Stage 3运行时只留 JRE Python dwg2dxfFROM eclipse-temurin:17-jre-jammyRUN apt-get install -y python3 python3-venv \ python3 -m venv /opt/venv \ /opt/venv/bin/pip install ezdxf1.3.5 matplotlib3.9.2 PillowCOPY --fromlibredwg /opt/libredwg/usr/local/bin/ /usr/local/bin/COPY --frombuild /build/target/dwg-converter.jar /app/app.jarENTRYPOINT [/usr/bin/tini, --, sh, -c, exec java $JAVA_OPTS -jar /app/app.jar]端口这块容器内用 SERVER_PORT 环境变量控制端口映射两边跟着同一个变量走改一处就够# 默认 8080docker run -d -p 8080:8080 dwg-converter:1.0.0# 换成 9000docker run -d -e SERVER_PORT9000 -p 9000:9000 dwg-converter:1.0.0镜像本身是 Linux 容器在 Windows 的 Docker Desktop 和 Linux 上都能跑。六、拿真实图纸跑一遍代码写完当然得测。除了 LibreDWG 官方的测试样本我拿了一张真实的工程图纸 —— EM-DP01外观.dwg276 KB文件头是 AC1032也就是 AutoCAD 2018 格式图层有 0、10000_FRONT、10500_FRONT_LABELING。结果这一测直接测出两个大坑。先看最终效果图 3 EM-DP01外观.dwg 转换效果200 DPI、白底 PNG2338×3340图上 1050 个实体全部正常渲染。不过这个结果是修完坑之后的中间过程相当曲折。七、踩到的三个坑坑一强制降级 DXF实体全丢这个是最坑的。我一开始为了「兼容性」给 dwg2dxf 加了 --as r2000想着输出老版本 DXF 兼容性更好。结果这张图转出来DXF 只有 27 KB实体数是 0渲染出来是一张纯白页PDF 只有 1.2 KB。要命的是接口返回的是 HTTP 200。要不是我顺手看了一眼文件大小这个问题可能就直接上线了。图 4 同一张图纸降级与不降级的输出对比去掉 --as让它沿用源版本DXF 变成 963 KBmodel space 里 1050 个实体LINE 740 个、LWPOLYLINE 293 个、ARC 17 个。验证方法很简单用 ezdxf 数一下就知道import collections, ezdxfdoc ezdxf.readfile(EM-DP01.dxf)msp doc.modelspace()c collections.Counter(e.dxftype() for e in msp)print(modelspace total:, sum(c.values()), dict(c.most_common(5)))# 降级版modelspace total: 0 {}# 源版本modelspace total: 1050 {LINE: 740, LWPOLYLINE: 293, ARC: 17}所以 DWG_DXF_VERSION 的默认值我改成了空沿用源版本。降级这个选项还留着但默认不启用。坑二颜色 7 的实体在白底上直接隐形修完第一个坑PDF 从 1.2 KB 涨到 131 KB看着像是对了。但 PNG 还是 6 KB我心里咯噔一下又去数了一下像素 —— 非白像素 0 个还是白页。PDF 有内容、PNG 没有这就很奇怪了。查下来发现是 ezdxf 的默认配色策略问题。AutoCAD 的模型空间背景是黑色的颜色 7 代表「白/黑」跟随背景变化。ezdxf 默认的 background policy 也遵循这个约定于是把颜色 7 的实体渲染成了白色。而我的画布是白底 —— 白线画在白纸上可不就看不见了。这张图正好 1050 个实体全是 BYLAYER所有图层颜色都是 7所以全军覆没。解决办法是显式指定策略别用默认值def build_config(background, monochrome):bg (background or white).strip().lower()if bg in TRANSPARENT:background_policy BackgroundPolicy.WHITE # 透明底也要用深色线条elif bg black:background_policy BackgroundPolicy.BLACKelse:background_policy BackgroundPolicy.WHITEif monochrome:color_policy (ColorPolicy.MONOCHROME_LIGHT_BGif background_policy is BackgroundPolicy.WHITEelse ColorPolicy.MONOCHROME_DARK_BG)else:color_policy ColorPolicy.COLORreturn Configuration(color_policycolor_policy, background_policybackground_policy)改完之后非白像素从 0 变成 47916 个。我把各种策略组合都跑了一遍结果挺说明问题的color policybackground policy非白像素COLORDEFAULT0COLORWHITE47916COLORMODELSPACE0MONOCHROME_LIGHT_BG任意47916BLACK任意47916WHITE任意0可以看到 BackgroundPolicy.DEFAULT 会走到 MODELSPACE深色只有 WHITE 才会把颜色 7 映射成黑色。这个默认值说实话挺容易踩的。坑三附带width 参数被静默忽略这个是修上面问题的时候顺手发现的。我传 width1200返回的图却是 800 px 宽。原因是 MatplotlibBackend 默认 adjust_figureTrue它会按图纸范围自己把 figure 缩放一遍我前面设的 figsize 就被覆盖了。改成 adjust_figureFalse再按图纸宽高比定画布尺寸尺寸才真正受控aspect drawing_aspect(layout) # 图纸的高宽比避免留白if width and height:fig_w, fig_h float(width) / dpi, float(height) / dpielif width:fig_w, fig_h float(width) / dpi, (float(width) / dpi) * aspectelif height:fig_h float(height) / dpifig_w fig_h / aspectelse:fig_w, fig_h DEFAULT_FIG_WIDTH, DEFAULT_FIG_WIDTH * aspectbackend MatplotlibBackend(ax, adjust_figureFalse) # 关键别让它自己缩放八、最终测试结果三个坑修完之后重新跑一遍全部 HTTP 200请求产物大小尺寸非白像素formatpdf142,247 B——formatpng232,674 B2338×3340146,880formatjpg287,698 B2338×3340328,454formatpng width1200106,529 B1200×171470,389formatpng dpi300 monochrometrue385,411 B3507×5010322,727convert/batch formatspdf,png,jpg398,361 B (ZIP)3 个条目—异常分支也都验过格式不支持返回 400文件后缀不对返回 422dpi 越界返回 422缺 file 参数返回 400。九、小结回过头看这次的坑基本都不在「写代码」上而在「默认值」上。dwg2dxf 的默认 DXF 版本、ezdxf 的默认背景策略、matplotlib 的默认 figure 调整三个默认值叠在一起产出的就是一张看起来完全正常、实际上一片空白的图。所以最想说的是转换类服务一定要验证输出内容不能只看 HTTP 200。像我这个场景最简单的判据就是数一下非白像素0 个就说明出问题了。这个校验我后来也加到了测试里。几个能直接抄走的结论dwg2dxf 不要加 --as 降级沿用源版本ezdxf 渲染白底图必须显式设 BackgroundPolicy.WHITEMatplotlibBackend 要用 adjust_figureFalse否则尺寸参数全失效转换服务别信退出码和 HTTP 状态码要验产物本身完整工程已经整理好了docker build -t dwg-converter:1.0.0 . 就能构建端口用 SERVER_PORT 控制。有做类似需求的朋友欢迎评论区交流。