Series Articles of Java Learning -- 02

Java项目实录02 -- 基于阿里云的普通增值税发票识别工具开发

Posted by OUC_LiuX on March 31, 2021

写完崇教授的程序后,想趁着还熟悉开发流程,一些如GUI之类的组件也还能复用,写个发票识别工具。 这篇博客主要记录项目开发的流程,一些共性的问题和基本语法问题会在其他博客中详细记录。
所有我们使用poi库读写生成的excel表格均需要是97-03版式.xls后缀的远古表格; 07及以后的.xlsx后缀表格无法读写生成。

项目结构

Ticket2Excel    
|   
|--GenereteExcel        
|  |    
|  |--Generate    
|  |--Ticket2Excel     
|   
|--utils    
|  |     
|  |--Base64Utils     
|  |--HttpUtils     
|  |--PicUtils     
|  |--Utils    
|    
|--gui    
|  |--FileChooser    

代码解析

已经在上一篇项目实录中介绍过的内容不再赘述,但会给出提示。

主函数类: Ticket2Excel

main函数: main()

new Runnable(){...}线程安全启动不再赘述。

静态方法 submit2AliAPI(String, String)

该方法内容大致可分为两部分,后半部分是结果解析,也就是怎么通过API返回的结果生成Excel表格,是解读的重点。前半部分主要是http实例构建、请求发送、参数设置、结果获取相关的内容。这些知识一是现阶段并不熟悉,二者也可能大部分都是固定的流程,本无需进行解读;但考虑到有些内容牵扯到其他知识点或者前后两部分的衔接,还是则其要者尝试解读,或许以后学习的更深入以后会对http部分进行补充。

第一部分–http

定义适用于http传输的NameValuePair(来自包org.apache.http)链表变量:

List<NameValuePair> params = new ArrayList<NameValuePair>();   

关于接口类List和抽象类AbstractList的实现类ArrayList之间关系的小知识,见博客java学习实录2

下一步,参照这里关于图片Base64编码的知识对图片编码,并将相应的base64码构造为键值对加入到参数链表params中:

params.add(new BasicNameValuePair("AI_VAT_INVOICE_IMAGE", imgBase64));

此处注意的是new BasicNameValuePair(key, value),同ListArrayList之间的关系一样,apache.http中的NameValuePair定义了一种接口类型,它无法通过new具体实现。而apache.http.message中的BasicNameValuePair则是适用于这个接口的具体类。

后面是 HttpGet 实例的构建和请求参数的设置,一是不熟,二是或许就是固定流程,不做解读。

接下来是发送 HttpGet 请求,通过httpClient.execute(httpPost)实现。其中httpPost是上一步构建的HttpPost类型实例;httpClient被声明为一个CloseableHttpClient类型变量,但通过HttpClient(均来自org.apache.http.impl.client)的静态方法createDefault()定义。
发送请求使用的execute()方法会返回一个HttpResponse类型变量,该变量(本项目设为execute)里包含着结果信息。比如可以通过.getStatueLine().getStatueCode()获取结果信息状态码已查看结果返回状态。该API中定义状态码为200为返回正常。

在接下来就是获取结果信息。execute.getEntity()会返回一个HttpEntity类型变量,既是结果。但是这个结果对我们不是直观可读的,通过EntityUtils的静态方法EntityUtils.parseString(HttpEntity)可以将HttpEntity类型的结果转化为字符串类型。由于结果中包含多种信息,显然json对我们的处理更加方便。

JSON(来自com.alibaba.fastjson)中的静态方法JSON.parseObject(String)可以将字符串形式表示的结果转化为JSONObject变量res_obj

第二部分–结果解析

从上一步得到的JSONObject类型变量res_obj中将结果各部分提取出来,用到合适的位置。就本项目发票API而言:

  • 发票抬头是一个字符串变量,直接通过res_obj.getString(key)提取。

  • 发票日期是一个字符串变量,直接通过res_obj.getString(key)提取。
    不同的是,这个日期是“year-month-day”格式的字符串变量。我们不希望出现中间的小短横‘-’,于是通过Utils的静态方法IntegerOnly()进行提取结果为纯数字。由于涉及到一些有关正则匹配的共性知识,方法的具体实现在java学习实录2中单独解读。

  • 发票总额是一个浮点型变量,所以通过res_obj.getString(key)提取出后,需要再通过Double的静态方法Double.parseDouble(String)将之转化为double型的 基本数据类型 变量。基本类型这一点很重要,Double还有一个静态方法Double.valueOf(String)返回一个Double型的 类类型 变量。关于Double 和 double的区别与联系依然见于java学习实录2中。

  • 发票detail是一个JSONArray变量,由多个具体条目组成,每个条目以JSONObject表现。通过res_obj.getJSONArray(key)提取。

内容提取结束。然后定义一个String类型变量作为生成的Excel文件的名字,使用String.format()方法填充抬头、日期、总额等发票信息对该字符串变量赋值。

接下来创建一个Generator()对象,并调用其方法(不能是静态方法,原因存疑)generator.generateExcel(invoice_detail, excel_name);生成指定名字的excel表格。

最后返回状态码return stateCode;结束该静态方法。

类: Generator

该类用于生成excel表格,需要使用poi.hssf库:

import org.apache.poi.hssf.usermodel.*;     

方法 generateExcel(JSONArray inputJson, String outputName)

// 该方法需要被其他对象调用,权限为public。
Params:    
@ inputJson    发票detail信息json数组类型      
@ outputName   生成excel表格的名字

回忆Excel表格的组成,自顶向下为:
工作薄(Workbook) –> 页面(sheet) –> 行(row) –> 单元格(cell),
依次而不能跨层地,下一层是上一层的组成部分,上一层是下一层存在的基础。

那么首先构造一个工作薄对象:HSSFWorkbook wb = new HSSFWorkbook();显然我们还需要且只需要一个页面(sheet),于是再给工作薄wb构造一个页面对象,命名为”sheet1”:HSSFSheet sheet = wb.createSheet("sheet1")。接下来可以再给sheet1构造一个行(row)对象作为表头:HSSFRow row = sheet.createRow(0),参数零表示作为页面的第一行,这样在调用表头构造方法的时候可以直接将该对象作为参数传入。当然也可以不事先构造,只是本项目如此做了。

随后分别调用类中私有方法this.generateHeadRow(params)this.generateBodyRow(params)写如表头和主题单元格内容。

写文件
填充完表格内容就要将之写入文件了,文件读写操作都要包装在

try{
    ...
}catch(IOException e){
    e.printStackTrace();
}   

语句中用以捕获可能发生的IO错误。文件读写是固定的流程:

// 1. 以文件名为参 构造文件对象;
File newExcelFile = new File(outputName);    

// 2. 以文件对象为参构造文件输出流对象;     
FileOutputStream out = new FileOutputStream(newExcelFile);    

// 3. 以文件输出流为参数写入内容;    
wb.write(out);    

// 4. 关闭文件输出流。     
out.close();     

需要考虑,表头有一些重要的项需要加红标注,单元格内容的对齐方式和字体也有要求。从而我们需要主动设置单元格的风格HSSFCellStyle
类中定义两个HSSFCellStyle类型的私有全局变量style_redstyle_normal分别表示加红和正常的单元格格式,通过私有方法generateStyle设置赋值。可以在构造函数Generator()或者本方法中中调用赋值方法完成赋值,总之在写单元格之前完成赋值就可以。

方法 generateStyle(HSSFWorkbook wb, short color)

private HSSFCellStyle getnerateStyle(HSSFWorkbook wb, short color){
    /******************************************    
    * 方法用以返回特定的单元格格式对象,从而在填充单元格式主动为单元格设置其格式。    
    * Params:   
    * @ wb, HSSFWorkbook 工作薄对象     
    * @ color, short 类型变量     
    *     
    * return 返回值     
    * style, HSSFCellStyle 单元格格式 对象       
    *******************************************/    

    // 从本工作薄构造HSSF单元格格式对象,并设置对齐方式。    
    // 是否由本工作薄的构造的单元格格式对象只能应用于本工作薄,存疑。   
    HSSFCellStyle style = wb.createCellStyle();    
    style.setAlignment(LEFT);  //居左    

    // 从本工作薄构造HSSF字体对象,并设置字号颜色和字体等。    
    // 是否由本工作薄构造的字体对象只适用于本工作薄,存疑;    
    // 是否字号设置方法的参数必须要强制转换为short,存疑。
    HSSFFont font = wb.createFont();   
    font.setFontHeightInPoints((short) 11);   
    // 参数传入的color, 其值为HSSFFont.COLOR_NORMAL/RED
    font.setColor(color);    
    font.setFontName("宋体");    

    // 设置style字体为已设置好的font,并返回。    
    style.setFont(font);
    return style;
}

方法 enerateHeadRow(HSSFRow row)

接受已构造的表格行(第一行)对象为参数,填充表头内容:

// 构造单元格对象,参数是第几列。一次定义,   
// 后面只要改参数重新赋值继续使用就可以。   
HSSFCell cell = row.createCell(0);   
cell.setCellValue("*物资名称(必填)");   // 填内容    
cell.setCellStyle(style_red);         // 设置格式     

重复这三行内容,往后填就行,没什么好说的。

方法 generateBodyRows(Params)

Params:   
@ inputJson, JSONArray类型发票内容也即表格内容      
@ sheet, HSSFSheet对象表格页面对象的值传过来是内存地址可以直接用   

for循环开始填充:

for (int i = 0; i < inputJson.size(); i ++){
    JSONArray obj = inputJson.getJSONObject(i);
    ......
}

一个for填充一行表格,由于JSONArray里的内容需要使用对象的getJSONObject(index)方法提取而不是直接下标[index]取值,从而似乎无法使用for each语句完成循环。

for 循环内:

obj.getString(key)取值,通过Ticket2Excel.java文件中第111-114行内容可以将API返回的原始json文件保存下来,从而查看obj的key值;
浮点数可以直接通过obj.getDouble提取;
税率是百分数,只能提取到String,但可以replace('%', '')将百分号去掉后再Double.parseDouble()转String为double,当然,还需要结果乘以0.01;
物资的真额(净额+税额)通过(税率+1.0)*净额得到。其值一般是整数,考虑到计算机二进制计算误差,可能会出现xxx.000001或xxx.999999类似的值,于是以(int)round(真额)将之四舍五入并取整。

随后填充单元格:

HSSFRow row = sheet.createRow(i+1);    // 构造新行    
// index是列数,从0开始;表头有多少列,这一行就重复多少次。   
// 当然,index和value要对应地填充。    
row.createRow(index).setCellValue(value); 

GUI类: FileChooser

静态方法 createWindow()

权限为public,由于需要项目主函数调用。

setLayout(...)指定窗口布局,依然是原始的东西南北中布局,参数填new BorderLayout();随后构造和add各Panel;最后不要忘记设置JFrame窗口属性

frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);   //如何退出     
frame.setSize(500, 400);            // 原始尺寸
frame.setResizable(true);           // 不解释
frame.setLocationRelativeTo(null);  // 窗口在屏幕居中出现
frame.setVisible(true);             // 不解释

静态方法 createNorthPanel()

上部(北)面板,用于输入路径选择。一般地,我们会先取当前路径作为默认值:

File direction = new File("");    
path = direction.getAbsolutePath();    // path是类全局变量。

需要注意的是,项目实录1中,我们在整个panel上通过new GridLayout(3, 2)的方式创造了一个包含两行三列内容的面板,其显示效果却不尽人意。如下图,三列宽度均等十分不和谐。

于是在本项目中,我们采用另一种更灵活的布局方式:以NorthPanel为具有BorderLayout布局方式的母面板,另行构造三个两行一列的GridLayout布局的子面板labelPaneltextPanelbuttonPanel,分别add到母面板NorthPanelWESTCENTEREAST三个位置。母面板的上下Panel缺失会自动被其他部分占据;母面板的子面板宽度能够根据组件的宽度自行适应,这样得到的界面更加好看:

该部分另一个需要注意的地方是button的添加,由于其已经在项目实录01进行过解读,且源码足够清晰,这里不再解读。

静态方法: createButton()

信息展示panel,需要注意的方面有自动换行和滚动条的添加;和项目实录01一致,不再解读。

静态方法: createSouthPanel()

按键面板,包含开始按键和退出按键,并为按键添加事件监听器以响应鼠标点击事件:

button.addActionListener(new ActionListener(){    
    @Override    
    public void actionPerformed(ActionEvent e){
        ...code...
    }
})

endButton比较简单,事件监听器内容有一行: System.exit(0);在系统层级完全结束进程。下面仅对startButton的事件监听器内容加以解读。

new File(direction)可以一次性读取路径下所有文件,之后调用File对象的listFiles()可以得到File[]类型的文件列表,随后for(variable: collection)语句遍历所有文件。具体解读见项目实录01

需要注意,在对文件合法性进行判断的时候,由于本项目还有一部对图像进行压缩的操作,而压缩后的图像会被另存为以原名+“_compress”为名称的新文件,所以有一步判断字符串是否含有某子字符(子串)的操作。学习实录2中总结了3种方法,这里由于已知特定子串(_compress)的具体位置(后缀长度一直,文件名串长度可length()方法提取),我们使用较高效的str.startWith(subStr, offset)方法,其参数中的offset业绩起始位置就是str.length() 减 后缀长度

随后,调用静态方法PicUtils.compressPicForScale(params)方法压缩图片到指定大小,调用静态方法Ticket2Excel.submit2AliAPI(parmas)提交图片到API,同时接受方法返回的状态码。并打印信息到centerPanelJTextArea中。需要注意的仍然是每次更新提交都需要做到内容实时刷新

类 PicUtils

通过google的thumbnailator库,压缩图片到指定大小。

静态方法 commpressPicForScale(params)

Param:   
@ srcPathString类型原图片路径     
@ desPathString类型压缩后目标图片路径     
@ desFileSize int型目标图片大小单位Kb    
@ accuracydouble类型迭代压缩过程中每次压缩的比例    

return:  
void方法无返回值    

首先进行路径合法性判断,接受的路径参数是否存在,或文件读出来是否为空:

if (StringUtils.isEmpty(srcPath) || !new File(scrPath).exist()){
    return;}

包装在try-catch里的内容
以文件方式读图像,并通过(int) (.length()/1024)得到以kb为单位的图像大小,这一步非必须操作,目的是将原图像大小打印到控制台,方便查看。
Thumbnails.of(srcPath).scale(1f).toFile(desPath);将原图像以jpg形式另为目标图像路径,调用的各方法和参数暂不展开讨论,有时间回头剖析。
调用compressPicCycle(desPath, desSize, accuracy)方法递归压缩
同样的方法打印压缩后图像的尺寸,不细表,结束。

静态方法 compressPicCycle(params)

Params:   
@ desPathString型变量目标图像路径    
@ desSizeint型变量目标图像大小单位Kb     
@ accuracy,double型变量每次大小变化的比例      

该方法递归运行,需要给一个跳出递归的判定条件,也即图像小于目标大小时return

if (srcFileSize < desSize * 1024){return;}

其中srcFileSize是目标图像大小,通过读图像为File后取length()得到,默认单位是字节’b’,于是在和以“Kb”为单位的desSize比较时将desSize乘以1024。

读图像,取宽高 ,并设置新的压缩图像的宽高为accuracy*取得的原宽高。这一步找到的教程使用了大数乘法:

int desWidth = new BigDecimal(srcWidth).
    multiply(new BigDecimal(accuracy)).intValue()     

但我觉得基本数据类型完全能hold住,不知用意何在。

灵魂的两步,首先设置本次迭代要压缩的的目标尺寸和精度比,并将压缩结果输出到相同路径;随后调用方法自身,完成递归:

Thumbnails.of(desPath).size(desWidth, desHeight)
          .outputQuality(accuracy).toFile(desPath);    
this.compressPicCicle(desPath, desSize, accuracy);   

结束