|
@@ -0,0 +1,190 @@
|
|
|
|
|
+package com.ruoyi.app.order.invoice;
|
|
|
|
|
+
|
|
|
|
|
+import com.google.zxing.BarcodeFormat;
|
|
|
|
|
+import com.google.zxing.EncodeHintType;
|
|
|
|
|
+import com.google.zxing.MultiFormatWriter;
|
|
|
|
|
+import com.google.zxing.common.BitMatrix;
|
|
|
|
|
+import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
|
|
|
|
|
+import org.apache.pdfbox.pdmodel.PDDocument;
|
|
|
|
|
+import org.apache.pdfbox.pdmodel.PDPage;
|
|
|
|
|
+import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
|
|
|
|
+import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
|
|
|
|
+import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
|
|
|
|
+import org.apache.pdfbox.pdmodel.graphics.state.RenderingMode;
|
|
|
|
|
+import org.springframework.stereotype.Component;
|
|
|
|
|
+
|
|
|
|
|
+import java.io.ByteArrayOutputStream;
|
|
|
|
|
+import java.io.InputStream;
|
|
|
|
|
+import java.math.BigDecimal;
|
|
|
|
|
+import java.util.ArrayList;
|
|
|
|
|
+import java.util.List;
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+
|
|
|
|
|
+/** 财政部2026-04-22附件一格式一;毫米坐标,不把57mm证明联缩放成普通小票。 */
|
|
|
|
|
+@Component
|
|
|
|
|
+public class InvoiceProofPdfRenderer {
|
|
|
|
|
+ private static final float MM = 72f / 25.4f;
|
|
|
|
|
+ private static final float WIDTH = 57 * MM;
|
|
|
|
|
+ private static final float TEXT_SIZE = 9.2f;
|
|
|
|
|
+ private static final String FONT = "/invoice/fonts/NotoSansTC-Regular.ttf";
|
|
|
|
|
+ private static final String PRIZE_URL = "https://invoice.etax.nat.gov.tw/";
|
|
|
|
|
+
|
|
|
|
|
+ public byte[] proof(InvoicePrintSnapshot s, boolean reprint) throws Exception {
|
|
|
|
|
+ try (PDDocument doc = new PDDocument(); InputStream input = getClass().getResourceAsStream(FONT)) {
|
|
|
|
|
+ PDType0Font font = PDType0Font.load(doc, input, true);
|
|
|
|
|
+ List<String> detail = detailLines(s, font);
|
|
|
|
|
+ // B2B明细与证明联同页不裁切;B2C另页,对应纸卷裁切边界。
|
|
|
|
|
+ float height = s.businessBuyer() ? 94 + detail.size() * 4.5f + 5 : 90;
|
|
|
|
|
+ InvoicePrintSnapshot.require(height < 5000, "tooLarge");
|
|
|
|
|
+ PDPage page = addPage(doc, height);
|
|
|
|
|
+ try (var out = new PDPageContentStream(doc, page)) {
|
|
|
|
|
+ text(out, page, font, s.sellerName(), 2, 8, TEXT_SIZE, false, 53);
|
|
|
|
|
+ // 字高不通过缩小字号适配;长标题仅水平压缩,补印必须与标题并列。
|
|
|
|
|
+ text(out, page, font, "電子發票證明聯" + (reprint ? " 補印" : ""), 2, 17, 19, false, 53);
|
|
|
|
|
+ text(out, page, font, s.period(), 2, 26, 22, true, 53);
|
|
|
|
|
+ text(out, page, font, s.invoiceNumber(), 2, 35, 22, true, 53);
|
|
|
|
|
+ text(out, page, font, s.issuedAt(), 2, 41, TEXT_SIZE, false, 53);
|
|
|
|
|
+ text(out, page, font, "隨機碼" + s.randomNum() + " 總計" + n(s.total()), 2, 46, TEXT_SIZE, false, 53);
|
|
|
|
|
+ text(out, page, font, "賣方" + s.sellerUbn(), 2, 51, TEXT_SIZE, false, 53);
|
|
|
|
|
+ if (s.businessBuyer()) {
|
|
|
|
|
+ text(out, page, font, "買方" + s.buyerUbn() + " 格式25", 2, 56, TEXT_SIZE, false, 53);
|
|
|
|
|
+ }
|
|
|
|
|
+ matrix(out, page, s.barCode(), BarcodeFormat.CODE_39, "UTF-8", 2, 59, 53, 6);
|
|
|
|
|
+ String charset = qrCharset(s.qrLeft());
|
|
|
|
|
+ // 两码的实际黑白符号同高同宽、上缘对齐;外侧保留至少四模块白色静区。
|
|
|
|
|
+ // 不能将不同版本二维码连同静区一起缩成同尺寸,否则实际符号大小不同。
|
|
|
|
|
+ matrix(out, page, s.qrLeft(), BarcodeFormat.QR_CODE, charset, 5, 68.5f, 17.5f, 17.5f);
|
|
|
|
|
+ matrix(out, page, s.qrRight(), BarcodeFormat.QR_CODE, charset, 34, 68.5f, 17.5f, 17.5f);
|
|
|
|
|
+ if (s.businessBuyer()) lines(out, page, font, detail, 94);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!s.businessBuyer()) detailPage(doc, font, detail);
|
|
|
|
|
+ return save(doc);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 供App查看或普通重复打印,故意不含证明联标题及兑奖条码,也不占用首次打印次数。 */
|
|
|
|
|
+ public byte[] detail(InvoicePrintSnapshot s) throws Exception {
|
|
|
|
|
+ try (PDDocument doc = new PDDocument(); InputStream input = getClass().getResourceAsStream(FONT)) {
|
|
|
|
|
+ PDType0Font font = PDType0Font.load(doc, input, true);
|
|
|
|
|
+ detailPage(doc, font, detailLines(s, font));
|
|
|
|
|
+ return save(doc);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 背面单独返回,避免普通热敏打印机误将背面内容顺序打印到证明联正面。 */
|
|
|
|
|
+ public byte[] reverse() throws Exception {
|
|
|
|
|
+ try (PDDocument doc = new PDDocument(); InputStream input = getClass().getResourceAsStream(FONT)) {
|
|
|
|
|
+ PDType0Font font = PDType0Font.load(doc, input, true);
|
|
|
|
|
+ PDPage page = addPage(doc, 90);
|
|
|
|
|
+ try (var out = new PDPageContentStream(doc, page)) {
|
|
|
|
|
+ lines(out, page, font, List.of("領獎收據", "金額(新臺幣):", "______________________",
|
|
|
|
|
+ "中獎人簽名(正楷)或蓋章:", "______________________", "電話:________________", "",
|
|
|
|
|
+ "兌獎若有疑義,請洽客服專線:", "(手機請加02)4128282;",
|
|
|
|
|
+ "或請至下列網址查詢", PRIZE_URL), 7);
|
|
|
|
|
+ matrix(out, page, PRIZE_URL, BarcodeFormat.QR_CODE, "UTF-8", 18.5f, 63, 20, 20);
|
|
|
|
|
+ }
|
|
|
|
|
+ return save(doc);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static String qrCharset(String left) {
|
|
|
|
|
+ String[] sections = left.substring(77).split(":", 6);
|
|
|
|
|
+ InvoicePrintSnapshot.require(sections.length >= 5 && ("0".equals(sections[4]) || "1".equals(sections[4])), "invalidData");
|
|
|
|
|
+ return "0".equals(sections[4]) ? "Big5" : "UTF-8";
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static List<String> detailLines(InvoicePrintSnapshot s, PDType0Font font) throws Exception {
|
|
|
|
|
+ List<String> result = new ArrayList<>();
|
|
|
|
|
+ for (String header : List.of("交易明細(非兌獎憑證)", s.sellerName(), "發票號碼 " + s.invoiceNumber(), s.issuedAt())) {
|
|
|
|
|
+ wrap(result, header, font);
|
|
|
|
|
+ }
|
|
|
|
|
+ for (var item : s.items()) {
|
|
|
|
|
+ wrap(result, item.name(), font);
|
|
|
|
|
+ wrap(result, "數量 " + n(item.quantity()) + " 單價 " + n(item.unitPrice()), font);
|
|
|
|
|
+ wrap(result, "金額 " + n(item.amount()) + " TX", font);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (s.businessBuyer()) {
|
|
|
|
|
+ wrap(result, "應稅銷售額 " + n(s.sales()), font);
|
|
|
|
|
+ wrap(result, "零稅率銷售額 0", font);
|
|
|
|
|
+ wrap(result, "免稅銷售額 0", font);
|
|
|
|
|
+ wrap(result, "稅額 " + n(s.tax()), font);
|
|
|
|
|
+ }
|
|
|
|
|
+ wrap(result, "總計 " + n(s.total()), font);
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static void wrap(List<String> lines, String value, PDType0Font font) throws Exception {
|
|
|
|
|
+ StringBuilder line = new StringBuilder();
|
|
|
|
|
+ // 按Unicode码点换行,长品名不截断,不用省略号丢失开票明细。
|
|
|
|
|
+ for (int point : value.codePoints().toArray()) {
|
|
|
|
|
+ if (Character.isISOControl(point)) point = ' ';
|
|
|
|
|
+ String character = new String(Character.toChars(point));
|
|
|
|
|
+ if (font.getStringWidth(line + character) / 1000 * TEXT_SIZE > 53 * MM && !line.isEmpty()) {
|
|
|
|
|
+ lines.add(line.toString()); line.setLength(0);
|
|
|
|
|
+ }
|
|
|
|
|
+ line.append(character);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!line.isEmpty()) lines.add(line.toString());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static void detailPage(PDDocument doc, PDType0Font font, List<String> detail) throws Exception {
|
|
|
|
|
+ float height = Math.max(40, 12 + detail.size() * 4.5f);
|
|
|
|
|
+ InvoicePrintSnapshot.require(height < 5000, "tooLarge");
|
|
|
|
|
+ PDPage page = addPage(doc, height);
|
|
|
|
|
+ try (var out = new PDPageContentStream(doc, page)) { lines(out, page, font, detail, 7); }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static void lines(PDPageContentStream out, PDPage page, PDType0Font font, List<String> lines, float top) throws Exception {
|
|
|
|
|
+ for (String line : lines) {
|
|
|
|
|
+ if (!line.isEmpty()) text(out, page, font, line, 2, top, TEXT_SIZE, false, 53);
|
|
|
|
|
+ top += 4.5f;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static PDPage addPage(PDDocument doc, float heightMm) {
|
|
|
|
|
+ var page = new PDPage(new PDRectangle(WIDTH, heightMm * MM)); doc.addPage(page); return page;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static void text(PDPageContentStream out, PDPage page, PDType0Font font, String value,
|
|
|
|
|
+ float x, float top, float size, boolean bold, float maxWidth) throws Exception {
|
|
|
|
|
+ value = value.replaceAll("\\p{Cntrl}", " ");
|
|
|
|
|
+ out.saveGraphicsState();
|
|
|
|
|
+ out.beginText(); out.setFont(font, size);
|
|
|
|
|
+ // 采用同字体描边加粗,避免数字/年期别替换字体后改变繁体字形与实际字高。
|
|
|
|
|
+ if (bold) { out.setRenderingMode(RenderingMode.FILL_STROKE); out.setLineWidth(0.35f); }
|
|
|
|
|
+ float width = font.getStringWidth(value) / 1000 * size;
|
|
|
|
|
+ out.setHorizontalScaling(Math.min(100, maxWidth * MM / width * 100));
|
|
|
|
|
+ out.newLineAtOffset(x * MM, page.getMediaBox().getHeight() - top * MM);
|
|
|
|
|
+ out.showText(value); out.endText(); out.restoreGraphicsState();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static void matrix(PDPageContentStream out, PDPage page, String value, BarcodeFormat format,
|
|
|
|
|
+ String charset, float x, float top, float width, float height) throws Exception {
|
|
|
|
|
+ // 原始码值绘制成矢量矩形;不经有损压缩、不截断,也不将店名/金额重新编码成假二维码。
|
|
|
|
|
+ Map<EncodeHintType, Object> hints = format == BarcodeFormat.QR_CODE
|
|
|
|
|
+ ? Map.of(EncodeHintType.CHARACTER_SET, charset, EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.L,
|
|
|
|
|
+ EncodeHintType.MARGIN, 0)
|
|
|
|
|
+ : Map.of(EncodeHintType.MARGIN, 20);
|
|
|
|
|
+ BitMatrix bits = new MultiFormatWriter().encode(value, format, 0, 0, hints);
|
|
|
|
|
+ float dx = width * MM / bits.getWidth(), dy = height * MM / bits.getHeight();
|
|
|
|
|
+ float left = x * MM;
|
|
|
|
|
+ if (format == BarcodeFormat.CODE_39) {
|
|
|
|
|
+ // 19位Code39在57mm纸上若拉伸为分数像素,203dpi热敏输出会破坏窄宽条比例。
|
|
|
|
|
+ // 以203dpi整数点宽居中排版,保留10模块左右静区;PDF仍是可缩放矢量。
|
|
|
|
|
+ dx = 72f / 203;
|
|
|
|
|
+ left = (float) Math.floor((WIDTH / dx - bits.getWidth()) / 2) * dx;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (int row = 0; row < bits.getHeight(); row++) {
|
|
|
|
|
+ for (int col = 0; col < bits.getWidth(); col++) {
|
|
|
|
|
+ if (bits.get(col, row)) out.addRect(left + col * dx,
|
|
|
|
|
+ page.getMediaBox().getHeight() - top * MM - (row + 1) * dy, dx, dy);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ out.fill();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static byte[] save(PDDocument doc) throws Exception {
|
|
|
|
|
+ var output = new ByteArrayOutputStream(); doc.save(output); return output.toByteArray();
|
|
|
|
|
+ }
|
|
|
|
|
+ private static String n(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
|
|
|
|
|
+}
|