ソースを参照

修复发票字体缺少简体字导致PDF生成失败

qmj 20 時間 前
親
コミット
abc46815ea

+ 4 - 3
ruoyi-admin/src/main/java/com/ruoyi/app/order/invoice/InvoicePrintAssetService.java

@@ -65,9 +65,10 @@ public class InvoicePrintAssetService {
             asset.setStatus("FAILED"); asset.setFailureCode("QUERY_OR_RENDER_FAILED");
             int[] minutes={1,5,15,60};
             asset.setNextRetryAt(new Date(System.currentTimeMillis()+60000L*minutes[Math.min(asset.getAttempts()-1,3)]));
-            // 不输出含随机码/二维码/密钥的异常正文,按发票ID和尝试次数排查。
-            log.warn("Invoice print assets failed, invoiceId={}, attempt={}, cause={}",
-                    invoice.getId(),asset.getAttempts(),failure.getClass().getSimpleName());
+            // 不记录请求或发票快照;保留异常堆栈,才能定位缺字、字体资源或图像处理错误。
+            // 供应商请求失败已由query服务转成业务异常,不在此输出原始供应商回应。
+            log.warn("Invoice print assets failed, invoiceId={}, attempt={}",
+                    invoice.getId(), asset.getAttempts(), failure);
         }
         asset.setUpdateTime(new Date());
         require((asset.getId()==null?assets.insert(asset):assets.updateById(asset))==1,"saveFailed");

+ 6 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/invoice/InvoicePrintSnapshot.java

@@ -108,5 +108,11 @@ public record InvoicePrintSnapshot(String sellerName, String invoiceNumber, Stri
 
     static boolean blank(String value) { return value == null || value.isBlank(); }
     static void require(boolean valid, String code) { if (!valid) throw error(code); }
+    /** 对外仍返回国际化业务提示,cause仅供服务端日志追踪实际字体/渲染异常。 */
+    static ServiceException error(String code, Throwable cause) {
+        ServiceException failure = error(code);
+        failure.initCause(cause);
+        return failure;
+    }
     static ServiceException error(String code) { return new ServiceException(MessageUtils.message("invoice.print." + code)); }
 }

+ 2 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/invoice/InvoiceProofPdfRenderer.java

@@ -27,7 +27,8 @@ public class InvoiceProofPdfRenderer {
     private static final float WIDTH = 57 * MM;
     // 57mm纸面居中保留48mm有效区域;字体高度不变,只调整横向布局。
     private static final float TEXT_SIZE = 9.2f;
-    private static final String FONT = "/invoice/fonts/NotoSansTC-Regular.ttf";
+    // 使用完整CJK而非繁体Subset,店名和商品允许简体、繁体及英文混排;随JAR嵌入,不依赖系统字体。
+    private static final String FONT = "/invoice/fonts/NotoSansCJKtc-Regular.ttf";
     private static final String PRIZE_URL = "https://invoice.etax.nat.gov.tw/";
 
     public byte[] proof(InvoicePrintSnapshot s, boolean reprint) throws Exception {

+ 2 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/order/invoice/MerchantInvoicePrintService.java

@@ -55,7 +55,7 @@ public class MerchantInvoicePrintService {
         try {
             return new DetailResult(invoice.getInvoiceNumber(), jobs.countForInvoice(invoice.getId(), invoice.getInvoiceTransNo()),
                     invoice.getInvoiceNumber() + "-detail.pdf", "application/pdf", encode(renderer.detail(data)));
-        } catch (Exception e) { throw error("renderFailed"); }
+        } catch (Exception e) { throw error("renderFailed", e); }
     }
 
     @Transactional(rollbackFor = Exception.class)
@@ -112,7 +112,7 @@ public class MerchantInvoicePrintService {
                     encode(job.getPdfContent()), encode(renderer.reverse()), InvoicePrintImageRenderer.PROFILE,
                     InvoicePrintImageRenderer.DOTS_PER_MM, blank(job.getImagePages())?java.util.List.of():
                     com.alibaba.fastjson2.JSON.parseArray(job.getImagePages(),InvoicePrintImage.class));
-        } catch (Exception e) { throw error("renderFailed"); }
+        } catch (Exception e) { throw error("renderFailed", e); }
     }
     private static String normalize(String text) { return blank(text) ? "" : text.trim(); }
     private static String encode(byte[] bytes) { return Base64.getEncoder().encodeToString(bytes); }

BIN
ruoyi-admin/src/main/resources/invoice/fonts/NotoSansCJKtc-Regular.ttf


BIN
ruoyi-admin/src/main/resources/invoice/fonts/NotoSansTC-Regular.ttf


+ 11 - 4
ruoyi-admin/src/main/resources/invoice/fonts/README.md

@@ -1,5 +1,12 @@
-# Noto Sans TC
+# Noto Sans CJK TC Regular
 
-来源:https://github.com/notofonts/noto-cjk/blob/main/Sans/Variable/TTF/Subset/NotoSansTC-VF.ttf
-许可证:同目录 LICENSE(SIL Open Font License 1.1)。
-NotoSansTC-Regular.ttf 由源可变字体固定 wght=400 生成,其他字形未更改。PDFBox 按PDF实际使用字形子集嵌入,不依赖服务器系统字体。
+使用完整 CJK 字体而非 `Subset/NotoSansTC-VF.ttf`。保留台湾繁体字形风格,同时覆盖简体、繁体、英文、数字及常用标点。不宣称覆盖全部 Unicode 或 Emoji。
+
+- 上游:https://raw.githubusercontent.com/notofonts/noto-cjk/f8d157532fbfaeda587e826d4cd5b21a49186f7c/Sans/Variable/TTF/NotoSansCJKtc-VF.ttf
+- 上游提交:`f8d157532fbfaeda587e826d4cd5b21a49186f7c`
+- 原文件 SHA-256:`6b40dd16cf1b73b5a7a0ed2472d641b60e3e85e87211abd20009f67d841425da`
+- 本文件 SHA-256:`b9508c4d400f7b1a53dfb1289e170e108ad25185db771eae07262537b9df782a`
+- 许可证:同目录 LICENSE(SIL Open Font License 1.1),字体内版权声明保留。
+- 生成:FontTools 4.68.0,`instantiateVariableFont(font, {'wght': 400}, inplace=True, updateFontNames=True)`,保存静态 TrueType;未裁剪字符覆盖范围,映射 44810 个码点。
+
+PDFBox 根据每份 PDF 实际使用的字形做嵌入子集,不依赖服务器安装字体。旧繁体子集缺少“测、试、简”等简体字,2026-11-02修复使用本完整版本。

+ 22 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/invoice/InvoiceProofPdfRendererTest.java

@@ -85,6 +85,28 @@ class InvoiceProofPdfRendererTest {
         }
     }
 
+    @Test void simplifiedTraditionalAndEnglishCanRenderTogetherInProofAndDetail() throws Exception {
+        var response = InvoicePrintSnapshotTest.data();
+        response.getJSONArray("ItemDetail").getJSONObject(0).put("ItemName", "草莓冰淇淋 Strawberry 123,简体繁體");
+        var snapshot = InvoicePrintSnapshot.from(response, InvoicePrintSnapshotTest.invoice(),
+                InvoicePrintSnapshotTest.config(), "测试門店 Foodie 123");
+        var renderer = new InvoiceProofPdfRenderer();
+        byte[] detail = assertDoesNotThrow(() -> renderer.detail(snapshot));
+        byte[] proof = assertDoesNotThrow(() -> renderer.proof(snapshot, false));
+        write("mixed-language-detail.pdf", detail); write("mixed-language-proof.pdf", proof);
+        for (byte[] bytes : java.util.List.of(detail, proof)) {
+            try (var doc = Loader.loadPDF(bytes)) {
+                String text = new PDFTextStripper().getText(doc).replaceAll("\\s", "");
+                assertTrue(text.contains("测试門店Foodie123"));
+                assertTrue(text.contains("草莓冰淇淋Strawberry123,简体繁體"));
+            }
+        }
+        var images = assertDoesNotThrow(() -> new InvoicePrintImageRenderer().render(proof));
+        assertEquals(384, images.getFirst().width());
+        Files.write(Path.of("target/invoice-print-samples/mixed-language-proof.png"),
+                java.util.Base64.getDecoder().decode(images.getFirst().pngBase64()));
+    }
+
     private static BufferedImage crop(BufferedImage image, float x, float y, float w, float h) {
         double scale = 203 / 25.4;
         int left = (int) (x * scale), top = (int) (y * scale);

+ 22 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/invoice/MerchantInvoicePrintServiceTest.java

@@ -131,6 +131,28 @@ class MerchantInvoicePrintServiceTest {
         }
     }
 
+    @Test void detailRenderingFailurePreservesCauseForServerDiagnostics() throws Exception {
+        setup(); when(invoices.selectOne(any())).thenReturn(InvoicePrintSnapshotTest.invoice());
+        var failure = new IllegalArgumentException("No glyph for U+6D4B");
+        when(renderer.detail(any())).thenThrow(failure);
+        try (var messages = mockStatic(MessageUtils.class, a -> a.getArgument(0))) {
+            var result = assertThrows(ServiceException.class, () -> service.detail(17L));
+            assertEquals("invoice.print.renderFailed", result.getMessage());
+            assertSame(failure, result.getCause());
+        }
+    }
+
+    @Test void printingResponseFailurePreservesCauseWithoutExposingItInBusinessMessage() throws Exception {
+        var request = setup();
+        var failure = new java.io.IOException("font resource unreadable");
+        when(renderer.reverse()).thenThrow(failure);
+        try (var messages = mockStatic(MessageUtils.class, a -> a.getArgument(0))) {
+            var result = assertThrows(ServiceException.class, () -> service.print(request, 10L));
+            assertEquals("invoice.print.renderFailed", result.getMessage());
+            assertSame(failure, result.getCause());
+        }
+    }
+
     @Test void voidInvoiceIsRejectedBeforeProviderQuery() throws Exception {
         var request = setup(); var invoice = InvoicePrintSnapshotTest.invoice(); invoice.setInvoiceStatus(3);
         when(invoices.selectForPrintUpdate(17L)).thenReturn(invoice);

+ 5 - 0
specs/010-order-invoice/frontend-app-printing.md

@@ -120,3 +120,8 @@
 - 正式使用前需在真实V3上检查纸张尺寸、文字、Code39和两个二维码;后端本地图片回读不等于真实热敏打印验收或官方认可。
 
 商米SDK参考:[内置打印机开发者文档](https://cdn.sunmi.com/public/generalfile/mgt-document/cb26f141d70b49f592a098db9e8a6114.pdf)。
+
+
+### 2026-11-02 字体修复说明
+
+后端字体改为完整 Noto Sans CJK TC(固定 Regular 字重),随 JAR 打包。支持简繁中文、英文、数字与常用标点混排,App 不安装字体、不转换文字,打印仍使用后端图片。历史“测试门店”缺字导致的失败文件,更新后端后会在原定重试时间重新生成;不需要重新开票,本次无新增 SQL。此前 Phase12/13 迁移仍须已执行。特殊 Emoji 等不保证覆盖,遇到不支持的字形保留服务端错误原因,不静默丢字。

+ 10 - 0
specs/010-order-invoice/plan.md

@@ -214,3 +214,13 @@ src/lang/{zh,tw,en,vi}.js                    # 四语言加 key
 
 
 2026-11-02 执行记录:T070–T074本地验证完成,36项定向测试全部通过且模块package通过。排版测试先复现旧版内容越出48mm有效区(第20像素),调整后通过;384点PNG回读三种码通过。任务独立持有调度器,不注册全局TaskScheduler,避免把现有支付定时任务意外切换到打印线程。未修改OrderInvoiceService开票/付款流程。代码审查未发现P1/P2。本轮随代码提交;未部署、SQL未执行,T075保留外部验收。
+
+
+### 2026-11-02 字体缺字修复计划
+
+1. 用简体门店名、繁体商品、英文/数字/标点混排复现PDFBox缺字,并验证当前包装错误丢失cause。
+2. 将完整NotoSansCJKtc-VF.ttf固定wght=400后打包,记录上游提交、SHA256、生成方式及SIL OFL许可;替换渲染器资源路径,不依赖系统字体。
+3. 保留渲染原始异常链,后台仅记录异常和发票ID,不输出供应商快照或凭据。
+4. 定向Invoice/小票测试、模块构建、JAR字体校验、只读真实订单3248验证和PNG视觉/条码回读;不部署、不写数据库。
+
+2026-11-02 字体修复验证:39项定向测试及模块package通过;新增缺字/cause回归先失败后通过。真实订单3248只读生成PDF与PNG成功,三种码回读一致;JAR校验新字体存在且旧子集已移除。没有迁移或数据写入;按用户要求提交推送,由用户发布。

+ 5 - 0
specs/010-order-invoice/spec.md

@@ -260,3 +260,8 @@
 - 相同 requestId 返回已保存的同一打印文件/图片,不能用缓存再次出原件。旧打印记录没有图片时返回空 images,App 提示使用补印流程,不任意缩放旧版 PDF。图片生成完成不等于真实打印完成,本轮不新增设备回执业务。
 - 平台查看优先已申请打印的最新文件;没有打印记录时显示当前发票已预生成原件。查看不联网、不生成、不计次数;重开后不读旧文件。
 - 仍保留打印时 ezPay 有效性校验;预生成减少排版/栅格化耗时,不承诺打印申请完全无第三方网络等待。图片不能公开暴露,通过原有鉴权接口返回 Base64。
+
+
+## 2026-11-02 发票字体覆盖修复
+
+订单3248的真实ezPay查询已成功,旧Noto Sans TC繁体子集缺少U+6D4B(测),导致简体门店名无法生成交易明细及证明联。改用完整Noto Sans CJK TC字体,保留台湾字形风格,同时覆盖简繁中文、英文、数字、常用标点;不转写、不丢弃原始文本,不承诺所有Unicode/Emoji均有字形。字体仍作为classpath资源随JAR部署,不要求服务器安装字体。原件/补印版式和金额规则不变。保留渲染异常cause供服务端定位,不在业务响应中暴露异常细节。已生成成功的文件保持原快照;失败文件按既有重试时间重新生成,无需重新开票或迁移SQL。

+ 16 - 0
specs/010-order-invoice/tasks.md

@@ -331,3 +331,19 @@ description: "Task list for 订单 ezPay 电子发票开立"
 - 独立代码审查未发现P1/P2;git diff --check通过。未执行全项目测试、真实MySQL、真实ezPay或V3出纸;T075保留。
 - 数据库需要手动建 `pos_order_invoice_print_asset` 并为 `pos_order_invoice_print_job` 添加 `image_pages`,见2026-11-02迁移。仅写脚本,未连接数据库执行。
 - 本轮未修改App或平台前端;现有平台PDF按钮直接读取预生成文件。前端交付 `frontend-app-printing.md`。
+
+
+## 2026-11-02 简繁中英文混排缺字修复
+
+状态:修复与本地验证完成,按用户要求随本轮提交推送;由用户发布。
+- [x] T076 只读查询订单3248并调用ezPay查询,在本地复现No glyph for U+6D4B;混排与异常cause测试3项均先失败。
+- [x] T077 替换完整CJK字体、记录许可来源、保留渲染cause和后台异常堆栈。
+- [x] T078 定向测试/构建/字体打包检查、真实订单只读验证、更新App文档。
+
+不新增DDL,不重新开票,不改打印次数或发票金额。
+
+验证:新增3项用例先失败(缺U+6D4B、两处cause丢失),修复后JDK21定向Invoice/小票39项通过,失败/错误/跳过均0;同次package通过,日志 `ruoyi-admin/target/invoice-font-verification.log`。JAR完整字体与源码SHA256一致,旧子集已移除。PNG混排视觉检查通过。
+
+只读真实订单3248:ezPay查询SUCCESS;交易明细PDF 11729字节、证明联PDF 20062字节、PNG 2段,Code39及左右二维码回读一致。没有更新数据库、创建打印申请、消耗次数或重新开票。新的静态字体21658028字节(21.7MB/20.7MiB),来源/校验和/固定字重过程记入字体README。
+
+本轮随代码提交推送,未部署;无需新增SQL。已存在的失败任务发布后按原重试时间恢复,不重置已有成功文件或打印资格;实机出纸仍属于T075外部验收。