diff --git a/README.md b/README.md index 985f2733..6946e2c0 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ com.github.hui.media audio-plugin - 1.1 + 2.0 ``` @@ -97,7 +97,7 @@ com.github.hui.media date-plugin - 1.1 + 2.0 ``` @@ -110,7 +110,7 @@ com.github.hui.media image-plugin - 1.1 + 2.0 ``` @@ -122,7 +122,7 @@ markdown转html,转图片的封装类, 详细内容查看: [markdown-plugin] com.github.hui.media markdown-plugin - 1.3 + 2.0 ``` @@ -134,7 +134,7 @@ markdown转html,转图片的封装类, 详细内容查看: [markdown-plugin] com.github.hui.media phantom-plugin - 1.1 + 2.0 ``` @@ -146,7 +146,7 @@ markdown转html,转图片的封装类, 详细内容查看: [markdown-plugin] com.github.hui.media qrcode-plugin - 1.2 + 2.0 ``` @@ -158,7 +158,7 @@ markdown转html,转图片的封装类, 详细内容查看: [markdown-plugin] com.github.hui.media svg-core - 1.1 + 2.0 ``` @@ -171,7 +171,7 @@ markdown转html,转图片的封装类, 详细内容查看: [markdown-plugin] com.github.hui.media imagic-core - 1.1 + 2.0 ``` @@ -200,21 +200,21 @@ markdown转html,转图片的封装类, 详细内容查看: [markdown-plugin] - 自定义图片 - [x] 二维码信息解析 -[查看更多二维码详情](doc/qrcode/QrGuide.md) +[查看更多二维码详情](https://liuyueyi.github.io/quick-media/#/插件/二维码/使用zxing提供二维码生成解析服务) ### 3. 图片 - [x] 长图文生成 - - [水平文字,上下布局长图文生成](doc/images/imgGenV1.md) - - [垂直文字,左右布局长图文生成](doc/images/imgGenV2.md) + - [水平文字,上下布局长图文生成](https://liuyueyi.github.io/quick-media/#/插件/image/Java实现长图文生成) + - [垂直文字,左右布局长图文生成](https://liuyueyi.github.io/quick-media/#/插件/image/Java实现竖排长图文生成) - 第三方字体支持 - [x] markdown 转 image - - [markdown 转 html](doc/md/md2html.md) - - [html 转 image](doc/md/html2image.md) - - [利用phantomjs实现html转image](doc/images/html2img.md) + - [markdown 转 html](https://liuyueyi.github.io/quick-media/#/插件/markdown/markdown转html) + - [html 转 image](https://liuyueyi.github.io/quick-media/#/markdown转image) + - [利用phantomjs实现html转image](https://liuyueyi.github.io/quick-media/#/插件/phantom/Java&PhantomJs实现html输出图片) - [x] gif图生成 - [x] 合成 - - [图片合成支持](doc/images/imgMerge.md) + - [图片合成支持](https://liuyueyi.github.io/quick-media/#/插件/image/图片合成) - [x] 水印 - [x] svg渲染 - [x] 裁剪 @@ -224,7 +224,7 @@ markdown转html,转图片的封装类, 详细内容查看: [markdown-plugin] - [ ] 格式转换 -[查看更多图片服务详情](doc/images/ImgGuide.md) +[查看更多图片服务详情](https://liuyueyi.github.io/quick-media/#/插件/image/概览) ### 4. 视频相关 - [ ] 视频压缩 @@ -280,23 +280,14 @@ markdown转html,转图片的封装类, 详细内容查看: [markdown-plugin] ## IV. 文档 -- [音频转码服务说明](doc/audio.md) -- [二维码生成解析服务说明](doc/qrcode/QrGenV1.md) -- [二维码服务拓展说明](doc/qrcode/QrGenV2.md) -- [二维码生成深度定制](doc/qrcode/QrGenV3.md) -- [长图文生成支持](doc/images/imgGenV1.md) -- [竖排长图文生成支持](doc/images/imgGenV2.md) -- [markdown 转 html](doc/md/md2html.md) -- [html 转 image](doc/md/html2image.md) -- [图片合成支持](doc/images/imgMerge.md) -- [利用phantomjs实现html转image](doc/images/html2img.md) +所有使用以及技术文档,开发过程中一些常见问题汇总,可以点击👉: [quick-media文档](https://liuyueyi.github.io/quick-media/#/) ### 问题记录汇总 -- [图片旋转不生效问题](doc/questions/ImgRotate.md) -- [markdonw转图片中文乱码问题](doc/questions/md2imgChineseMessyCode.md) -- [兼容ImageIO读取jpeg图片变红](https://zbang.online/hexblog/public/2018/01/22/%E5%85%BC%E5%AE%B9ImageIO%E8%AF%BB%E5%8F%96jpeg%E5%9B%BE%E7%89%87%E5%8F%98%E7%BA%A2/) -- [Batik渲染png图片异常的bug修复](https://zbang.online/hexblog/public/2018/01/20/Batik%E6%B8%B2%E6%9F%93png%E5%9B%BE%E7%89%87%E5%BC%82%E5%B8%B8%E7%9A%84bug%E4%BF%AE%E5%A4%8D/) +- [图片旋转不生效问题](https://liuyueyi.github.io/quick-media/#/采坑/图片旋转问题修复) +- [markdonw转图片中文乱码问题](https://liuyueyi.github.io/quick-media/#/采坑/markdown转图片中文乱码) +- [兼容ImageIO读取jpeg图片变红](https://liuyueyi.github.io/quick-media/#/采坑/Batik渲染png图片异常的bug修复) +- [Batik渲染png图片异常的bug修复](https://liuyueyi.github.io/quick-media/#/采坑/兼容ImageIO读取jpeg图片变红) ## V. 其他 diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 47f0748a..dea64e3a 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -4,14 +4,38 @@ - 二、五分钟入门 - [1. 五分钟入门使用教程](入门/五分钟入门使用教程.md) - [2. 五分钟入门开发教程](入门/五分钟入门开发教程.md) -- 三、插件详解 - - [1. 二维码插件]() - - [2. AWT图片插件]() - - [3. svg渲染插件]() - - [4. 音频插件]() - - [5. 日期插件]() - - [6. Markdown转Html/Image插件]() - - [7. PhantomJS插件]() - - [8. ImageMagic插件]() +- 三、插件 + - [1. 二维码](#) + - [A. 概览](插件/二维码/二维码插件概览.md) + - [B. 使用zxing提供二维码生成解析服务](插件/二维码/使用zxing提供二维码生成解析服务.md) + - [C. 二维码的基础服务拓展](插件/二维码/二维码的基础服务拓展.md) + - [D. 二维码生成服务之深度定制](插件/二维码/二维码生成服务之深度定制.md) + - [2. image](#) + - [A. 概览](插件/image/概览.md) + - [B. Java实现长图文生成](插件/image/Java实现长图文生成.md) + - [C. Java实现竖排长图文生成](插件/image/Java实现竖排长图文生成.md) + - [D. 图片合成](插件/image/图片合成.md) + - [3. svg](#) + - [A. 概览](插件/svg/概览.md) + - [4. Phantomjs](#) + - [A. 概览](插件/svg/概览.md) + - [B. Java & PhantomJs 实现html输出图片](插件/phantom/Java&PhantomJs实现html输出图片.md) + - [5. ImageMagic](#) + - [A. 概览](插件/magic/概览.md) + - [B. Java 借助ImageMagic实现图片编辑服务](插件/magic/Java借助ImageMagic实现图片编辑服务.md) + - [6. Markdown/Html](#) + - [A. 概览](插件/markdonw/概览.md) + - [B. markdown转html](插件/markdown/markdown转html.md) + - [C. markdown转image](插件/markdown/markdown转image.md) + - [7. audio](#) + - [A. 概览](插件/audio/概览.md) + - [B. 利用FFMPEG实现一个音频转码服务](插件/audio/利用FFMPEG实现一个音频转码服务.md) + - [8. 老黄历](#) + - [A. 概览](插件/date/概览.md) - [四、更新日志](迭代/更新日志.md) -- [五、其他](其他/其他.md) \ No newline at end of file +- 五、采坑记录 + - [1. 图片旋转不生效问题](采坑/图片旋转问题修复.md) + - [2. markdown转图片中文乱码](采坑/markdown转图片中文乱码.md) + - [3. Batik渲染png图片异常的bug修复](采坑/Batik渲染png图片异常的bug修复.md) + - [4. 兼容ImageIO读取jpeg图片变红](采坑/兼容ImageIO读取jpeg图片变红.md) +- [六、其他](其他/其他.md) \ No newline at end of file diff --git "a/docs/\346\217\222\344\273\266/audio/\345\210\251\347\224\250FFMPEG\345\256\236\347\216\260\344\270\200\344\270\252\351\237\263\351\242\221\350\275\254\347\240\201\346\234\215\345\212\241.md" "b/docs/\346\217\222\344\273\266/audio/\345\210\251\347\224\250FFMPEG\345\256\236\347\216\260\344\270\200\344\270\252\351\237\263\351\242\221\350\275\254\347\240\201\346\234\215\345\212\241.md" new file mode 100644 index 00000000..4d3c6229 --- /dev/null +++ "b/docs/\346\217\222\344\273\266/audio/\345\210\251\347\224\250FFMPEG\345\256\236\347\216\260\344\270\200\344\270\252\351\237\263\351\242\221\350\275\254\347\240\201\346\234\215\345\212\241.md" @@ -0,0 +1,921 @@ +# 利用FFMPEG实现一个音频转码服务 +> 提供一个音频转码服务,主要是利用ffmpeg实现转码,利用java web对外提供http服务接口 + +## 背景 +> 音频转码服务算是比较基础的了,之前一直没做,最近有个需求背景,是将微信的amr格式音频,转换为mp3格式,否则h5页面的音频将无法播放 + +出于这个转码的场景,顺带着搭建一个多媒体处理服务应用(目标是图片的基本操作,音频、视频的常用操作等) + +**拟采用的技术** + +1. 图片 + - imageMagic/graphicMagic + im4java + +2. 音频 + - ffmpeg + `Runtime.getRuntime().exec(cmd);` + +3. Spring Boot + Spring Mvc 提供http服务接口 + + +**本篇重点** + +使用ffmpeg提供音频转码的服务接口 + +## 准备 + +### 1. ffmpeg 安装 + +#### 安装脚本如下 + +```sh +#!/bin/bash + +## download ffmpge cmd +wget https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-64bit-static.tar.xz + + +## exact package +xz -d ffmpeg-release-64bit-static.tar.xz +tar -xvf ffmpeg-release-64bit-static.tar +mv ffmpeg-release-64bit-static ffmpeg +cd ffmpeg +``` + +#### 测试 + +进入下载的目录,内部有一个 `ffmpeg` 的可执行文件,主要利用它来实现音频转码 + + +`./ffmpeg -version` 查看ffmpeg的版本 + + +**转码测试** + +先准备一个测试文件 test.amr (不要直接从微信的文件夹中获取语音文件,微信做过处理,非标准的amr文件,如果手头没有,可以使用这个测试 [amrTestAudio.amr](http://git.oschina.net/liuyueyi/quicksilver/attach_files/download?i=86632&u=http%3A%2F%2Ffiles.git.oschina.net%2Fgroup1%2FM00%2F01%2F80%2FPaAvDFll2yKAZxSkAACkQOHKtLM001.amr%3Ftoken%3D409c7d98faa7a3ace3043f1e78c123b8%26ts%3D1499847458%26attname%3DamrTestAudio.amr) ) + +转码命令 + +``` +./ffmpeg -i test.amr test.mp3 +``` + +然后可以看到新增一个mp3文件,然后用播放器,打开确认是否有问题 + +### 2. 工程搭建 + +使用Spring-Boot 搭建一个Web工程 + +直接用官网的创建方式即可,这里不做叙述 + +### 3. 编码实现 +> java利用命令行操作方式调用ffmpeg,实现音频转码,一个最简单的实现如下 + + +```java +// cmd 为待执行的命令行 +String cmd = "ffmpeg -i src.amr test.mp3"; +Process process = Runtime.getRuntime().exec(cmd); +process.waitFor(); +``` + +就这样就可以了么? 显然并没有这么简陋,先谈谈直接这么用有什么问题 + +- 扩展性,不好 +- 命令行的输出流,异常流没有处理 +- 对调用者而言不够友好 +- 上面只适用于本地音频转码,如果是对远程的音频,数据流格式的音频就不怎么方便了 + +出于以上几点,着手实现我们的目标,先看最后的测试case: + + +```java +@Test +public void testAudioParse() { + String[] arys = new String[]{ + "test.amr", + "/Users/yihui/GitHub/quick-media/common/src/test/resources/test.amr", + "http://s11.mogucdn.com/mlcdn/c45406/170713_3g25ec8fak8jch5349jd2dcafh61c.amr" + }; + + for (String src : arys) { + try { + String output = AudioWrapper.of(src) + .setOutputType("mp3") + .asFile(); + System.out.println(output); + } catch (Exception e) { + e.printStackTrace(); + } + } +} +``` + +从使用的角度来看就很是简洁了,输出结果如下 + +```bash +/Users/yihui/GitHub/quick-media/common/target/test-classes/test_out.mp3 +/Users/yihui/GitHub/quick-media/common/src/test/resources/test_out.mp3 +/tmp/audio/170713_3g25ec8fak8jch5349jd2dcafh61c_out.mp3 +``` + +## 实现 + +前面准备做好,测试的case也提前放出,那么可以看下如何实现了 + +### 配置类 `AudioOptions` +> 保存最终命令的配置相关信息,用于生成最终的执行命令行 + +对于音频转码,最终的cmd命令应该是: `ffmpeg -i source.amr output.mp3`,因此我们需要的参数有 + +- 源文件 source.mar +- 输出文件 output.mp3 +- 执行命令 ffmpeg +- 可选参数 (ffmpeg带的一些参数) + +```java +public class AudioOptions { + + private String cmd = "ffmpeg -i "; + + private String src; + + + private String dest; + + + private Map options = new HashMap<>(); + + + public String getCmd() { + return cmd; + } + + public AudioOptions setCmd(String cmd) { + this.cmd = cmd; + return this; + } + + public String getSrc() { + return src; + } + + public AudioOptions setSrc(String src) { + this.src = src; + return this; + } + + public String getDest() { + return dest; + } + + public AudioOptions setDest(String dest) { + this.dest = dest; + return this; + } + + public Map getOptions() { + return options; + } + + + public AudioOptions addOption(String conf, Object value) { + options.put("-" + conf, value); + return this; + } + + + + public String build() { + StringBuilder builder = new StringBuilder(this.cmd); + builder.append(" ").append(this.src); + + for (Map.Entry entry : options.entrySet()) { + builder.append(entry.getKey().startsWith("-") ? " " : " -") + .append(entry.getKey()) + .append(" ").append(entry.getValue()); + } + + builder.append(" ").append(this.dest); + return builder.toString(); + } +} +``` + + +### Audio处理封装类 `AudioWrapper` +> 对外暴露的接口,所有音频相关的操作都通过它来执行,正如上面的测试用例 + +1. 对输入源,我们预留三种调用方式 + + - 传入path路径(相对路径,绝对路径,网络路径) + - URI 方式 (即传入网络链接方式,等同于上面的网络路径方式) + - InputStream (文件输入流) + +2. 命令行调用,通常可选参数比较多,所以我们采用Builder模式来做参数的设置 + +3. 源码如下 + +```java +@Slf4j +public class AudioWrapper { + + public static Builder of(String str) { + Builder builder = new Builder<>(); + return builder.setSource(str); + } + + + public static Builder of(URI uri) { + Builder builder = new Builder<>(); + return builder.setSource(uri); + } + + + public static Builder of(InputStream inputStream) { + Builder builder = new Builder<>(); + return builder.setSource(inputStream); + } + + + private static void checkNotNull(Object obj, String msg) { + if (obj == null) { + throw new IllegalStateException(msg); + } + } + + private static boolean run(String cmd) { + try { + return ProcessUtil.instance().process(cmd); + } catch (Exception e) { + log.error("operate audio error! cmd: {}, e: {}", cmd, e); + return false; + } + } + + + public static class Builder { + /** + * 输入源 + */ + private T source; + + + /** + * 源音频格式 + */ + private String inputType; + + + /** + * 输出音频格式 + */ + private String outputType; + + + /** + * 命令行参数 + */ + private Map options = new HashMap<>(); + + + /** + * 临时文件信息 + */ + private FileUtil.FileInfo tempFileInfo; + + + private String tempOutputFile; + + + public Builder setSource(T source) { + this.source = source; + return this; + } + + public Builder setInputType(String inputType) { + this.inputType = inputType; + return this; + } + + public Builder setOutputType(String outputType) { + this.outputType = outputType; + return this; + } + + public Builder addOption(String conf, Object val) { + this.options.put(conf, val); + return this; + } + + + private String builder() throws Exception { + + checkNotNull(source, "src file should not be null!"); + + checkNotNull(outputType, "output Audio type should not be null!"); + + + tempFileInfo = FileUtil.saveFile(source, inputType); + + tempOutputFile = tempFileInfo.getPath() + "/" + tempFileInfo.getFilename() + "_out." + outputType; + + return new AudioOptions().setSrc(tempFileInfo.getAbsFile()) + .setDest(tempOutputFile) + .addOption("y", "") // 覆盖写 + .addOption("write_xing", 0) // 解决mac/ios 显示音频时间不对的问题 + .addOption("loglevel", "quiet") // 不输出日志 + .build(); + } + + + public InputStream asStream() throws Exception { + String output = asFile(); + + if (output == null) { + return null; + } + + + return new FileInputStream(new File(output)); + } + + + public String asFile() throws Exception { + String cmd = builder(); + return !run(cmd) ? null : tempOutputFile; + } + } + +} +``` + + +上面的逻辑还是比较清晰的,但是有几个地方需要注意 + +- 保存源文件到指定目录下 `tempFileInfo = FileUtil.saveFile(source, inputType);` +- 执行命令的生成 : + ```java + new AudioOptions().setSrc(tempFileInfo.getAbsFile()) + .setDest(tempOutputFile) + .addOption("y", "") // 覆盖写 + .addOption("write_xing", 0) // 解决mac/ios 显示音频时间不对的问题 + .addOption("loglevel", "quiet") // 不输出日志 + .build(); + ``` +- java执行cmd命令 `private static boolean run(String cmd)` + + +### 文件保存 `FileUtil` +> 这个工具类的目的比较清晰, 将源文件保存到指定的临时目录下,根据我们支持的三种方式,进行区分处理 + + +我们定义一个数据结构 FileInfo 保存文件名相关信息 + +```java +@Getter +@Setter +@ToString +@NoArgsConstructor +@AllArgsConstructor +public static class FileInfo { + /** + * 文件所在的目录 + */ + private String path; + + + /** + * 文件名 (不包含后缀) + */ + private String filename; + + + /** + * 文件类型 + */ + private String fileType; + + + public String getAbsFile() { + return path + "/" + filename + "." + fileType; + } +} +``` + +根据输入,选择不同的实现方式保存,并返回文件信息 + +```java +public static FileInfo saveFile(T src, String inputType) throws Exception { + if (src instanceof String) { // 给的文件路径,区分三中,本地绝对路径,相对路径,网络地址 + return saveFileByPath((String) src); + } else if (src instanceof URI) { // 网络资源文件时,需要下载到本地临时目录下 + return saveFileByURI((URI) src); + } else if (src instanceof InputStream) { // 输入流保存在到临时目录 + return saveFileByStream((InputStream) src, inputType); + } else { + throw new IllegalStateException("save file parameter only support String/URI/InputStream type! but input type is: " + (src == null ? null : src.getClass())); + } + } + +``` + +#### 1. 输入源为String时 + +三种路径的区分,对于http的格式,直接走URI输入源的方式 + +相对路径时,需要优先获取文件的绝对路径 + +```java +/** + * 根据path路径 生成源文件信息 + * + * @param path + * @return + * @throws Exception + */ +private static FileInfo saveFileByPath(String path) throws Exception { + if (path.startsWith("http")) { + return saveFileByURI(URI.create(path)); + } + + + String tmpAbsFile; + if (path.startsWith("/")) { // 绝对路径 + tmpAbsFile = path; + } else { // 相对路径转绝对路径 + tmpAbsFile = FileUtil.class.getClassLoader().getResource(path).getFile(); + } + + // 根据绝对路径,解析 目录 + 文件名 + 文件后缀 + return parseAbsFileToFileInfo(tmpAbsFile); +} + +/** + * 根据绝对路径解析出 目录 + 文件名 + 文件后缀 + * + * @param absFile 全路径文件名 + * @return + */ +public static FileInfo parseAbsFileToFileInfo(String absFile) { + FileInfo fileInfo = new FileInfo(); + extraFilePath(absFile, fileInfo); + extraFileName(fileInfo.getFilename(), fileInfo); + return fileInfo; +} + + +/** + * 根据绝对路径解析 目录 + 文件名(带后缀) + * + * @param absFilename + * @param fileInfo + */ +private static void extraFilePath(String absFilename, FileInfo fileInfo) { + int index = absFilename.lastIndexOf("/"); + if (index < 0) { + fileInfo.setPath(TEMP_PATH); + fileInfo.setFilename(absFilename); + } else { + fileInfo.setPath(absFilename.substring(0, index)); + fileInfo.setFilename(index + 1 == absFilename.length() ? "" : absFilename.substring(index + 1)); + } +} + + +/** + * 根据带后缀文件名解析 文件名 + 后缀 + * + * @param fileName + * @param fileInfo + */ +private static void extraFileName(String fileName, FileInfo fileInfo) { + int index = fileName.lastIndexOf("."); + if (index < 0) { + fileInfo.setFilename(fileName); + fileInfo.setFileType(""); + } else { + fileInfo.setFilename(fileName.substring(0, index)); + fileInfo.setFileType(index + 1 == fileName.length() ? "" : fileName.substring(index + 1)); + } +} +``` + +#### 2. 输入源为URI时 +> 网络资源,需要先把文件下载过来,所以就需要一个下载的工具类 + +一个非常初级的下载工具类: `HttpUtil.java` + +```java +@Slf4j +public class HttpUtil { + + + public static InputStream downFile(String src) throws IOException { + return downFile(URI.create(src)); + } + + /** + * 从网络上下载文件 + * + * @param uri + * @return + * @throws IOException + */ + public static InputStream downFile(URI uri) throws IOException { + HttpResponse httpResponse; + try { + Request request = Request.Get(uri); + HttpHost httpHost = URIUtils.extractHost(uri); + if (StringUtils.isNotEmpty(httpHost.getHostName())) { + request.setHeader("Host", httpHost.getHostName()); + } + request.addHeader("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36"); + + httpResponse = request.execute().returnResponse(); + } catch (Exception e) { + log.error("远程请求失败,url=" + uri, e); + throw new FileNotFoundException(); + } + + int code = httpResponse.getStatusLine().getStatusCode(); + if (code != 200) { + throw new FileNotFoundException(); + } + + return httpResponse.getEntity().getContent(); + } +} +``` + +具体的保存代码,比较简单,从网络上下载的InputStream直接转换第三种使用方式即可 + +```java +/** + * 下载远程文件, 保存到临时目录, 病生成文件信息 + * + * @param uri + * @return + * @throws Exception + */ +private static FileInfo saveFileByURI(URI uri) throws Exception { + String path = uri.getPath(); + if (path.endsWith("/")) { + throw new IllegalArgumentException("a select uri should be choosed! but input path is: " + path); + } + + int index = path.lastIndexOf("/"); + String filename = path.substring(index + 1); + + FileInfo fileInfo = new FileInfo(); + extraFileName(filename, fileInfo); + fileInfo.setPath(TEMP_PATH); + + try { + InputStream inputStream = HttpUtil.downFile(uri); + return saveFileByStream(inputStream, fileInfo); + + } catch (Exception e) { + log.error("down file from url: {} error! e: {}", uri, e); + throw e; + } +} +``` + + +#### 3. 输入源为InpuStream时 +> 将输入流保存到文件 + +这是一个比较基础的功能了,但真正的实现起来,就没有那么顺畅了,需要注意一下几点 + +- 确保临时文件所在的目录存在 +- 输入输出流的关闭,输出流的`flush()`方法不要忘记 +- 保存的临时文件名为: 时间戳 + `[0-1000)随机数` +- 输出文件名为输入文件名的基础上加 + `"_out.输出格式"` + +```java +public static FileInfo saveFileByStream(InputStream inputStream, String fileType) throws Exception { + // 临时文件生成规则 当前时间戳 + 随机数 + 后缀 + return saveFileByStream(inputStream, TEMP_PATH, genTempFileName(), fileType); +} + + +/** + * 将字节流保存到文件中 + * + * @param stream + * @param filename + * @return + */ +public static FileInfo saveFileByStream(InputStream stream, String path, String filename, String fileType) throws FileNotFoundException { + return saveFileByStream(stream, new FileInfo(path, filename, fileType)); +} + + +public static FileInfo saveFileByStream(InputStream stream, FileInfo fileInfo) throws FileNotFoundException { + if (!StringUtils.isBlank(fileInfo.getPath())) { + mkDir(new File(fileInfo.getPath())); + } + + String tempAbsFile = fileInfo.getPath() + "/" + fileInfo.getFilename() + "." + fileInfo.getFileType(); + BufferedOutputStream outputStream = null; + InputStream inputStream = null; + try { + inputStream = new BufferedInputStream(stream); + outputStream = new BufferedOutputStream(new FileOutputStream(tempAbsFile)); + int len = inputStream.available(); + //判断长度是否大于4k + if (len <= 4096) { + byte[] bytes = new byte[len]; + inputStream.read(bytes); + outputStream.write(bytes); + } else { + int byteCount = 0; + //1M逐个读取 + byte[] bytes = new byte[4096]; + while ((byteCount = inputStream.read(bytes)) != -1) { + outputStream.write(bytes, 0, byteCount); + } + } + + return fileInfo; + } catch (Exception e) { + log.error("save stream into file error! filename: {} e: {}", tempAbsFile, e); + return null; + } finally { + try { + if (outputStream != null) { + outputStream.flush(); + outputStream.close(); + } + + if (inputStream != null) { + inputStream.close(); + } + } catch (IOException e) { + log.error("close stream error! e: {}", e); + } + } +} + + +/** + * 临时文件名生成: 时间戳 + 0-1000随机数 + * + * @return + */ +private static String genTempFileName() { + return System.currentTimeMillis() + "_" + ((int) (Math.random() * 1000)); +} + + +/** + * 递归创建文件夹 + * + * @param file 由目录创建的file对象 + * @throws FileNotFoundException + */ +public static void mkDir(File file) throws FileNotFoundException { + if (file.getParentFile().exists()) { + if (!file.exists() && !file.mkdir()) { + throw new FileNotFoundException(); + } + } else { + mkDir(file.getParentFile()); + if (!file.exists() && !file.mkdir()) { + throw new FileNotFoundException(); + } + } +} +``` + +#### 命令行执行封装工具类 `ProcessUtil` +> 这个就是将最上面的三行代码封装的工具类,基本上快两百行... + +源码先贴出 + +```java +@Slf4j +public class ProcessUtil { + + /** + * Buffer size of process input-stream (used for reading the + * output (sic!) of the process). Currently 64KB. + */ + public static final int BUFFER_SIZE = 65536; + + public static final int EXEC_TIME_OUT = 2; + + + private ExecutorService exec; + + private ProcessUtil() { + exec = new ThreadPoolExecutor(6, + 12, + 1, + TimeUnit.MINUTES, + new LinkedBlockingQueue<>(10), + new CustomThreadFactory("cmd-process"), + new ThreadPoolExecutor.CallerRunsPolicy()); + } + + + public static ProcessUtil instance() { + return InputStreamConsumer.instance; + } + + + /** + * 简单的封装, 执行cmd命令 + * + * @param cmd 待执行的操作命令 + * @return + * @throws IOException + * @throws InterruptedException + */ + public boolean process(String cmd) throws Exception { + Process process = Runtime.getRuntime().exec(cmd); + waitForProcess(process); + return true; + } + + /** + * Perform process input/output and wait for process to terminate. + * + * 源码参考 im4java 的实现修改而来 + * + */ + + private int waitForProcess(final Process pProcess) + throws IOException, InterruptedException, TimeoutException, ExecutionException { + // Process stdout and stderr of subprocess in parallel. + // This prevents deadlock under Windows, if there is a lot of + // stderr-output (e.g. from ghostscript called by convert) + FutureTask outTask = new FutureTask(() -> { + processOutput(pProcess.getInputStream(), InputStreamConsumer.DEFAULT_CONSUMER); + return null; + }); + exec.submit(outTask); + + + FutureTask errTask = new FutureTask(() -> { + processError(pProcess.getErrorStream(), InputStreamConsumer.DEFAULT_CONSUMER); + return null; + }); + exec.submit(errTask); + + + // Wait and check IO exceptions (FutureTask.get() blocks). + try { + outTask.get(); + errTask.get(); + } catch (ExecutionException e) { + Throwable t = e.getCause(); + + if (t instanceof IOException) { + throw (IOException) t; + } else if (t instanceof RuntimeException) { + throw (RuntimeException) t; + } else { + throw new IllegalStateException(e); + } + } + + FutureTask processTask = new FutureTask(() -> { + pProcess.waitFor(); + return pProcess.exitValue(); + }); + exec.submit(processTask); + + + // 设置超时时间,防止死等 + int rc = processTask.get(EXEC_TIME_OUT, TimeUnit.SECONDS); + + + // just to be on the safe side + try { + pProcess.getInputStream().close(); + pProcess.getOutputStream().close(); + pProcess.getErrorStream().close(); + } catch (Exception e) { + log.error("close stream error! e: {}", e); + } + + return rc; + } + + + ////////////////////////////////////////////////////////////////////////////// + + /** + * Let the OutputConsumer process the output of the command. + *

+ * 方便后续对输出流的扩展 + */ + + private void processOutput(InputStream pInputStream, + InputStreamConsumer pConsumer) throws IOException { + pConsumer.consume(pInputStream); + } + + ////////////////////////////////////////////////////////////////////////////// + + /** + * Let the ErrorConsumer process the stderr-stream. + *

+ * 方便对后续异常流的处理 + */ + + private void processError(InputStream pInputStream, + InputStreamConsumer pConsumer) throws IOException { + pConsumer.consume(pInputStream); + } + + + private static class InputStreamConsumer { + static ProcessUtil instance = new ProcessUtil(); + + static InputStreamConsumer DEFAULT_CONSUMER = new InputStreamConsumer(); + + void consume(InputStream stream) throws IOException { + StringBuilder builder = new StringBuilder(); + + BufferedReader reader = new BufferedReader(new InputStreamReader(stream), BUFFER_SIZE); + + String temp; + while ((temp = reader.readLine()) != null) { + builder.append(temp); + } + + + if (log.isDebugEnabled()) { + log.debug("cmd process input stream: {}", builder.toString()); + } + reader.close(); + } + } + + + private static class CustomThreadFactory implements ThreadFactory { + + private String name; + + private AtomicInteger count = new AtomicInteger(0); + + public CustomThreadFactory(String name) { + this.name = name; + } + + @Override + public Thread newThread(Runnable r) { + return new Thread(r, name + "-" + count.addAndGet(1)); + } + } + +} +``` + +**说明** + +- 内部类方式的单例模式 +- 线程池,开独立的线程来处理命令行的输出流、异常流 + - 如果不清空这两个流,可能直接导致rt随着并发数的增加而线性增加 +- 独立的线程执行命令行操作,支持超时设置 + - 超时设置,确保服务不会挂住 + - 异步执行命令行操作,可以并发执行后续的步骤 + + +## 填坑之旅 + +上面实现了一个较好用的封装类,但是在实际的开发过程中,有些问题有必要单独的拎出来说一说 + +### 1. `-y` 参数 +> 覆盖写,如果输出的文件名对应的文件已经存在,这个参数就表示使用新的文件覆盖老的 + +在控制台执行转码时,会发现这种场景会要求用户输入一个y/n来表是否继续转码,所以在代码中,如果不加上这个参数,将一直得不到执行 + + +### 2. mac/ios 的音频长度与实际不符合 + +将 amr 音频转换 mp3 格式音频,如果直接使用命令`ffmpeg -i test.amr -y out.mp3` + +会发现输出的音频时间长度比实际的小,但是在播放的时候又是没有问题的;测试在mac和iphone会有这个问题 + +解决方案,加一个参数 `write_xing 0` + + +### 3. 并发访问时,RT线性增加 + +执行命令: `ffmpeg -i song.ogg -y -write_xing 0 song.mp3` + +当我们没有手动清空输出流,异常流时,会发现并发请求量越高,rt越高 + +主要原因是输出信息 & 异常信息没有被消费,而缓存这些数据的空间是有限制的,因此上面我们的`ProcessUtil`类中,有两个任务来处理输出流和异常流 + +还有一种方法就是加一个参数 + +`ffmpeg -i song.ogg -y -write_xing 0 song.mp3 -loglevel quiet` + + +## 其他 + +项目源码: [https://github.com/liuyueyi/quick-media](https://github.com/liuyueyi/quick-media) diff --git "a/docs/\346\217\222\344\273\266/audio/\346\246\202\350\247\210.md" "b/docs/\346\217\222\344\273\266/audio/\346\246\202\350\247\210.md" new file mode 100644 index 00000000..3bf12917 --- /dev/null +++ "b/docs/\346\217\222\344\273\266/audio/\346\246\202\350\247\210.md" @@ -0,0 +1 @@ +# 概览 \ No newline at end of file diff --git "a/docs/\346\217\222\344\273\266/date/\346\246\202\350\247\210.md" "b/docs/\346\217\222\344\273\266/date/\346\246\202\350\247\210.md" new file mode 100644 index 00000000..3bf12917 --- /dev/null +++ "b/docs/\346\217\222\344\273\266/date/\346\246\202\350\247\210.md" @@ -0,0 +1 @@ +# 概览 \ No newline at end of file diff --git "a/docs/\346\217\222\344\273\266/image/Java\345\256\236\347\216\260\347\253\226\346\216\222\351\225\277\345\233\276\346\226\207\347\224\237\346\210\220.md" "b/docs/\346\217\222\344\273\266/image/Java\345\256\236\347\216\260\347\253\226\346\216\222\351\225\277\345\233\276\346\226\207\347\224\237\346\210\220.md" new file mode 100644 index 00000000..a70cf106 --- /dev/null +++ "b/docs/\346\217\222\344\273\266/image/Java\345\256\236\347\216\260\347\253\226\346\216\222\351\225\277\345\233\276\346\226\207\347\224\237\346\210\220.md" @@ -0,0 +1,586 @@ +# Java实现竖排长图文生成 +## 背景 +前面[Java 实现长图文生成](https://my.oschina.net/u/566591/blog/1514644) 中实现了一个基本的长图文生成工具,但遗留了一些问题 + +- 文字中包含英文字符时,分行计算问题 +- 暂不支持竖排文字展示 + +其中英文字符的计算已经修复,主要是通过`FontMetric`来计算字符串实际占用绘制的长度,这一块不做多讲,本篇主要集中在竖排文字的支持 + + +## 设计 + +> 有前面的基础,在做竖排文字支持上,本以为是比较简单就能接入的,而实际的实现过程中,颇为坎坷 + +### 1. 竖排文字绘制 + +首先需要支持竖排文字的绘制,使用`Graphics2d`进行绘制时,暂不支持竖排绘制方式,因此我们需要自己来实现 + +而设计思路也比较简单,一个字一个字的绘制,x坐标不变,y坐标依次增加 + +```java +private void draw(Graphics2D g2d, String content, int x, int y, FontMetrics fontMetrics) { + int lastY = y; + for (int i = 0; i < content.length(); i ++) { + g2d.drawString(content.charAt(i) + "", x, lastY); + lastY += fontMetrics.charWidth(content.charAt(i)) + fontMetrics.getDescent(); + } +} +``` + +### 2. 自动换行 +竖排的自动换行相比较与水平有点麻烦的是间隔问题,首先看下`FontMertric`的几个参数 `ascent`, `descent`, `height` + +![https://static.oschina.net/uploads/img/201709/05181941_gJBV.jpg](https://static.oschina.net/uploads/img/201709/05181941_gJBV.jpg) + +举一个例子来看如何进行自动换行 + +```java +// 列容量 +contain = 100 + +// FontMetric 相关信息: +fontMetric.ascent = 18; +fontMetric.descent = 4; +fontMetric.height = 22; + +// 待绘制的内容为 +content = "这是一个待绘制的文本长度,期待自动换行"; +``` + +首先我们是需要获取内容的总长度,中文还比较好说,都是方块的,可以直接用 `fontMetrics.stringWidth(content)` 获取内容长度(实际为宽度),然后需要加空格(即`descent`) + +所以计算最终的行数可以如下 + +```java +// 72 +int l = fontMetrics.getDescent() * (content.length() - 1); + // 5 +int lineNum = (int) Math.ceil((fontMetrics.stringWidth(str) + l) / (float) lineLen); +``` + +根据上面的计算, `l=72, lineNum=5;` + +然后就是一个字符一个字符的进行绘制,每次需要重新计算y坐标 + +```java +tmpLen = fontMetrics.charWidth(str.charAt(i)) + fontMetrics.getDescent(); +``` + +其次就是需要判断是否要换行 + +```java +lastTotal += tmpLen; +if(lastTotal > contain) { + // 换行 +} +``` + +### 3. 从右到左支持 + +从左到右还比较好说,y坐标一直增加,当绘制的内容超过当前的图片时,直接在扩展后的图片上(0,0)位置进行绘制即可; + +而从右到左则需要计算偏移量,如下图 + +![offset](https://static.oschina.net/uploads/img/201709/05182209_f6NC.jpg) + +## 实现 + +### 1. 文本自动换行 + +实现一个公共方法,根据上面的思路用于文本的自动换行 + +```java +public static String[] splitVerticalStr(String str, int lineLen, FontMetrics fontMetrics) { + // 字体间距所占用的高度 + int l = fontMetrics.getDescent() * (str.length() - 1); + // 分的行数 + int lineNum = (int) Math.ceil((fontMetrics.stringWidth(str) + l) / (float) lineLen); + + if (lineNum == 1) { + return new String[]{str}; + } + + + String[] ans = new String[lineNum]; + int strLen = str.length(); + int lastTotal = 0; + int lastIndex = 0; + int ansIndex = 0; + int tmpLen; + for (int i = 0; i < strLen; i++) { + tmpLen = fontMetrics.charWidth(str.charAt(i)) + fontMetrics.getDescent(); + lastTotal += tmpLen; + if (lastTotal > lineLen) { + ans[ansIndex++] = str.substring(lastIndex, i); + lastIndex = i; + lastTotal = tmpLen; + } + } + + if (lastIndex < strLen) { + ans[ansIndex] = str.substring(lastIndex); + } + + return ans; +} +``` + +上面的实现,唯一需要注意的是,换行时,y坐标自增的场景下,需要计算 `fontMetric.descent` 的值,否则换行偏移会有问题 + + +### 2. 垂直文本的绘制 + +#### 1. 起始y坐标计算 + +因为我们支持集中不同的对齐方式,所以在计算起始的y坐标时,会有出入, 实现如下 + +- 上对齐,则 y = 上边距 +- 下对其, 则 y = 总高度 - 内容高度 - 下边距 +- 居中, 则 y = (总高度 - 内容高度) / 2 + +```java +/** + * 垂直绘制时,根据不同的对其方式,计算起始的y坐标 + * + * @param topPadding 上边距 + * @param bottomPadding 下边距 + * @param height 总高度 + * @param strSize 文本内容对应绘制的高度 + * @param style 对其样式 + * @return + */ +private static int calOffsetY(int topPadding, int bottomPadding, int height, int strSize, ImgCreateOptions.AlignStyle style) { + if (style == ImgCreateOptions.AlignStyle.TOP) { + return topPadding; + } else if (style == ImgCreateOptions.AlignStyle.BOTTOM) { + return height - bottomPadding - strSize; + } else { + return (height - strSize) >> 1; + } +} +``` + +#### 2. 实际绘制y坐标计算 +实际绘制中,y坐标还不能直接使用上面返回值,因为这个返回是字体的最上边对应的坐标,因此需要将实际绘制y坐标,向下偏移一个字 + +```java +realY = calOffsetY(xxx) + fontMetrics.getAscent(); + +//... + +// 每当绘制完一个文本后,下个文本的Y坐标,需要加上这个文本所占用的高度+间距 +realY += fontMetrics.charWidth(tmp.charAt(i)) + g2d.getFontMetrics().getDescent(); +``` + + +#### 3. 换行时,x坐标计算 +绘制方式的不同,从左到右与从右到左两种场景下,自动换行后,新行的x坐标的增量计算方式也是不同的 + +- 从左到右:`int fontWidth = 字体宽度 + 行间距` +- 从右到左:`int fontWidth = - (字体宽度 + 行间距)` + + + +#### 完整的实现逻辑如下 + +```java + /** + * 垂直文字绘制 + * + * @param g2d + * @param content 待绘制的内容 + * @param x 绘制的起始x坐标 + * @param options 配置项 + */ +public static void drawVerticalContent(Graphics2D g2d, + String content, + int x, + ImgCreateOptions options) { + int topPadding = options.getTopPadding(); + int bottomPadding = options.getBottomPadding(); + + g2d.setFont(options.getFont()); + FontMetrics fontMetrics = g2d.getFontMetrics(); + + // 实际填充内容的高度, 需要排除上下间距 + int contentH = options.getImgH() - options.getTopPadding() - options.getBottomPadding(); + String[] strs = splitVerticalStr(content, contentH, g2d.getFontMetrics()); + + int fontWidth = options.getFont().getSize() + options.getLinePadding(); + if (options.getDrawStyle() == ImgCreateOptions.DrawStyle.VERTICAL_RIGHT) { // 从右往左绘制时,偏移量为负 + fontWidth = -fontWidth; + } + + + g2d.setColor(options.getFontColor()); + + int lastX = x, lastY, startY; + for (String tmp : strs) { + lastY = 0; + startY = calOffsetY(topPadding, bottomPadding, options.getImgH(), + fontMetrics.stringWidth(tmp) + fontMetrics.getDescent() * (tmp.length() - 1), options.getAlignStyle()) + + fontMetrics.getAscent(); + + for (int i = 0; i < tmp.length(); i++) { + g2d.drawString(tmp.charAt(i) + "", + lastX, + startY + lastY); + + lastY += g2d.getFontMetrics().charWidth(tmp.charAt(i)) + g2d.getFontMetrics().getDescent(); + } + lastX += fontWidth; + } +} +``` + + +### 3. 垂直图片绘制 + +文本绘制实现之后,再来看图片,就简单很多了,因为没有换行的问题,所以只需要计算y坐标的值即可 + + +此外当图片大于参数指定的高度时,对图片进行按照高度进行缩放处理;当小于高度时,就原图绘制即可 + +实现逻辑如下 + +```java +public static int drawVerticalImage(BufferedImage source, + BufferedImage dest, + int x, + ImgCreateOptions options) { + Graphics2D g2d = getG2d(source); + int h = Math.min(dest.getHeight(), options.getImgH() - options.getTopPadding() - options.getBottomPadding()); + int w = h * dest.getWidth() / dest.getHeight(); + + int y = calOffsetY(options.getTopPadding(), + options.getBottomPadding(), + options.getImgH(), + h, + options.getAlignStyle()); + + + // xxx 传入的x坐标,即 contentW 实际上已经包含了行间隔,因此不需额外添加 + int drawX = x; + if (options.getDrawStyle() == ImgCreateOptions.DrawStyle.VERTICAL_RIGHT) { + drawX = source.getWidth() - w - drawX; + } + g2d.drawImage(dest, drawX, y, w, h, null); + g2d.dispose(); + return w; +} +``` + + +### 4. 封装类的实现 + +正如前面一篇博文中实现的水平图文生成的逻辑一样,垂直图文生成也采用之前的思路: + +- 每次在文本绘制时,直接进行渲染; +- 记录实际内容绘制的宽度(这个宽度包括左or右边距) +- 每次绘制时,判断当前的画布是否容纳得下所有的内容 + - 容的下,直接绘制即可 + - 容不下,则需要扩充画布,生成一个更宽的画布,将原来的内容重新渲染在新画布上,然后在新画布上进行内容的填充 + +因为从左到右和从右到左的绘制在计算x坐标的增量时,扩充画布的重新绘制时,有些明显的区别,所以为了逻辑清晰,将两种场景分开,提供了两个方法 + +实现步骤: + +1. 计算实际绘制内容占用的宽度 +2. 判断是否需要扩充画布(需要则扩充) +3. 绘制文本 +4. 更新内容的宽度 + +```java +private Builder drawVerticalLeftContent(String content) { + if (contentW == 0) { // 初始化边距 + contentW = options.getLeftPadding(); + } + + Graphics2D g2d = GraphicUtil.getG2d(result); + g2d.setFont(options.getFont()); + FontMetrics fontMetrics = g2d.getFontMetrics(); + + + String[] strs = StringUtils.split(content, "\n"); + if (strs.length == 0) { // empty line + strs = new String[1]; + strs[0] = " "; + } + + int fontSize = fontMetrics.getFont().getSize(); + int lineNum = GraphicUtil.calVerticalLineNum(strs, options.getImgH() - options.getBottomPadding() - options.getTopPadding(), fontMetrics); + + // 计算填写内容需要占用的宽度 + int width = lineNum * (fontSize + options.getLinePadding()); + + + if (result == null) { + result = GraphicUtil.createImg( + Math.max(width + options.getRightPadding() + options.getLeftPadding(), BASE_ADD_H), + options.getImgH(), + null); + g2d = GraphicUtil.getG2d(result); + } else if (result.getWidth() < contentW + width + options.getRightPadding()) { + // 超过原来图片宽度的上限, 则需要扩充图片长度 + result = GraphicUtil.createImg( + result.getWidth() + Math.max(width + options.getRightPadding(), BASE_ADD_H), + options.getImgH(), + result); + g2d = GraphicUtil.getG2d(result); + } + + + // 绘制文字 + int index = 0; + for (String str : strs) { + GraphicUtil.drawVerticalContent(g2d, str, + contentW + (fontSize + options.getLinePadding()) * (index ++) + , options); + } + g2d.dispose(); + + contentW += width; + return this; +} + + +private Builder drawVerticalRightContent(String content) { + if(contentW == 0) { + contentW = options.getRightPadding(); + } + + Graphics2D g2d = GraphicUtil.getG2d(result); + g2d.setFont(options.getFont()); + FontMetrics fontMetrics = g2d.getFontMetrics(); + + + String[] strs = StringUtils.split(content, "\n"); + if (strs.length == 0) { // empty line + strs = new String[1]; + strs[0] = " "; + } + + int fontSize = fontMetrics.getFont().getSize(); + int lineNum = GraphicUtil.calVerticalLineNum(strs, options.getImgH() - options.getBottomPadding() - options.getTopPadding(), fontMetrics); + + // 计算填写内容需要占用的宽度 + int width = lineNum * (fontSize + options.getLinePadding()); + + + if (result == null) { + result = GraphicUtil.createImg( + Math.max(width + options.getRightPadding() + options.getLeftPadding(), BASE_ADD_H), + options.getImgH(), + null); + g2d = GraphicUtil.getG2d(result); + } else if (result.getWidth() < contentW + width + options.getLeftPadding()) { + // 超过原来图片宽度的上限, 则需要扩充图片长度 + int newW = result.getWidth() + Math.max(width + options.getLeftPadding(), BASE_ADD_H); + result = GraphicUtil.createImg( + newW, + options.getImgH(), + newW - result.getWidth(), + 0, + result); + g2d = GraphicUtil.getG2d(result); + } + + + // 绘制文字 + int index = 0; + int offsetX = result.getWidth() - contentW; + for (String str : strs) { + GraphicUtil.drawVerticalContent(g2d, str, + offsetX - (fontSize + options.getLinePadding()) * (++index) + , options); + } + g2d.dispose(); + + contentW += width; + return this; +} +``` + + +对比从左到右与从右到左,区别主要是两点 + +- 扩充时,在新画布上绘制原画布内容的x坐标计算,一个为0,一个为 `新宽度-旧宽度` +- offsetX 的计算 + + +上面是文本绘制,图片绘制比较简单,基本上和水平绘制时,没什么区别,只不过是扩充时的w,h计算不同罢了 + +```java +private Builder drawVerticalImage(BufferedImage bufferedImage) { + int padding = options.getDrawStyle() == ImgCreateOptions.DrawStyle.VERTICAL_RIGHT ? options.getLeftPadding() : options.getRightPadding(); + + // 实际绘制图片的宽度 + int bfImgW = bufferedImage.getHeight() > options.getImgH() ? bufferedImage.getWidth() * options.getImgH() / bufferedImage.getHeight() : bufferedImage.getWidth(); + if(result == null) { + result = GraphicUtil.createImg( + Math.max(bfImgW + options.getLeftPadding() + options.getRightPadding(), BASE_ADD_H), + options.getImgH(), + null); + } else if (result.getWidth() < contentW + bfImgW + padding) { + int realW = result.getWidth() + Math.max(bfImgW + options.getLeftPadding() + options.getRightPadding(), BASE_ADD_H); + int offsetX = options.getDrawStyle() == ImgCreateOptions.DrawStyle.VERTICAL_RIGHT ? realW - result.getWidth() : 0; + result = GraphicUtil.createImg( + realW, + options.getImgH(), + offsetX, + 0, + result); + } + + int w = GraphicUtil.drawVerticalImage(result, bufferedImage, contentW, options); + contentW += w + options.getLinePadding(); + return this; +} +``` + +### 5. 输出 + +上面是绘制的过程,绘制完毕之后,需要输出为图片的,因此对于这个输出需要再适配一把 + +再前一篇的基础上,输出新增了签名+背景的支持,这里一并说了 + +- 计算生成图片的宽高 +- 有签名时,绘制签名背景,在最下方绘制签名文本 +- 背景图片 +- 绘制填充内容 + +```java +public BufferedImage asImage() { + int leftPadding = 0; + int topPadding = 0; + int bottomPadding = 0; + if (border) { + leftPadding = this.borderLeftPadding; + topPadding = this.borderTopPadding; + bottomPadding = this.borderBottomPadding; + } + + + int x = leftPadding; + int y = topPadding; + + + // 实际生成图片的宽, 高 + int realW, realH; + if (options.getImgW() == null) { // 垂直文本输出 + realW = contentW + options.getLeftPadding() + options.getRightPadding(); + realH = options.getImgH(); + } else { // 水平文本输出 + realW = options.getImgW(); + realH = contentH + options.getBottomPadding(); + } + + BufferedImage bf = new BufferedImage((leftPadding << 1) + realW, realH + topPadding + bottomPadding, BufferedImage.TYPE_INT_ARGB); + Graphics2D g2d = GraphicUtil.getG2d(bf); + + + // 绘制边框 + if (border) { + g2d.setColor(borderColor == null ? ColorUtil.OFF_WHITE : borderColor); + g2d.fillRect(0, 0, realW + (leftPadding << 1), realH + topPadding + bottomPadding); + + + // 绘制签名 + g2d.setColor(Color.GRAY); + + // 图片生成时间 + String date = DateFormatUtils.format(new Date(), "yyyy-MM-dd HH:mm:ss"); + borderSignText = borderSignText + " " + date; + + int fSize = Math.min(15, realW / (borderSignText.length())); + int addY = (borderBottomPadding - fSize) >> 1; + g2d.setFont(new Font(ImgCreateOptions.DEFAULT_FONT.getName(), ImgCreateOptions.DEFAULT_FONT.getStyle(), fSize)); + g2d.drawString(borderSignText, x, y + addY + realH + g2d.getFontMetrics().getAscent()); + } + + + // 绘制背景 + if (options.getBgImg() == null) { + g2d.setColor(bgColor == null ? Color.WHITE : bgColor); + g2d.fillRect(x, y, realW, realH); + } else { + g2d.drawImage(options.getBgImg(), x, y, realW, realH, null); + } + + + // 绘制内容 + if (options.getDrawStyle() == ImgCreateOptions.DrawStyle.VERTICAL_RIGHT) { + x = bf.getWidth() - result.getWidth() - x; + } + g2d.drawImage(result, x, y, null); + g2d.dispose(); + return bf; +} +``` + + +## 测试 + +测试case + +```java +@Test +public void testLocalGenVerticalImg() throws IOException { + int h = 300; + int leftPadding = 10; + int topPadding = 10; + int bottomPadding = 10; + int linePadding = 10; + Font font = new Font("手札体", Font.PLAIN, 18); + + ImgCreateWrapper.Builder build = ImgCreateWrapper.build() + .setImgH(h) + .setDrawStyle(ImgCreateOptions.DrawStyle.VERTICAL_LEFT) + .setLeftPadding(leftPadding) + .setTopPadding(topPadding) + .setBottomPadding(bottomPadding) + .setLinePadding(linePadding) + .setFont(font) + .setAlignStyle(ImgCreateOptions.AlignStyle.TOP) + .setBgColor(Color.WHITE) + .setBorder(true) + .setBorderColor(0xFFF7EED6) + ; + + + BufferedReader reader = FileReadUtil.createLineRead("text/poem.txt"); + String line; + while ((line = reader.readLine()) != null) { + build.drawContent(line); + } + + build.setAlignStyle(ImgCreateOptions.AlignStyle.BOTTOM) + .drawImage("/Users/yihui/Desktop/sina_out.jpg"); + build.setFontColor(Color.BLUE).drawContent("后缀签名").drawContent("灰灰自动生成"); + + BufferedImage img = build.asImage(); + ImageIO.write(img, "png", new File("/Users/yihui/Desktop/2out.png")); +} +``` + +输出图片 + +![https://static.oschina.net/uploads/img/201709/05182105_2smp.jpg](https://static.oschina.net/uploads/img/201709/05182105_2smp.jpg) + +再输出一个从右到左的,居中显示样式 + +![https://static.oschina.net/uploads/img/201709/05182138_My1E.png](https://static.oschina.net/uploads/img/201709/05182138_My1E.png) + + +## 其他 +相关博文:[《Java 实现长图文生成》](https://my.oschina.net/u/566591/blog/1514644) + +项目地址:[https://github.com/liuyueyi/quick-media](https://github.com/liuyueyi/quick-media) + + +个人博客:[一灰的个人博客](http://blog.zbang.online:8080) + +公众号获取更多: + +![个人信息](https://static.oschina.net/uploads/img/201708/12175649_wn2r.png "个人信息") \ No newline at end of file diff --git "a/docs/\346\217\222\344\273\266/image/Java\345\256\236\347\216\260\351\225\277\345\233\276\346\226\207\347\224\237\346\210\220.md" "b/docs/\346\217\222\344\273\266/image/Java\345\256\236\347\216\260\351\225\277\345\233\276\346\226\207\347\224\237\346\210\220.md" new file mode 100644 index 00000000..b4c9de4c --- /dev/null +++ "b/docs/\346\217\222\344\273\266/image/Java\345\256\236\347\216\260\351\225\277\345\233\276\346\226\207\347\224\237\346\210\220.md" @@ -0,0 +1,721 @@ +# Java实现长图文生成 +> 很久很久以前,就觉得微博的长图文实现得非常有意思,将排版直接以最终的图片输出,收藏查看分享都很方便,现在则自己动手实现一个简单版本的 + +## 目标 + +首先定义下我们预期达到的目标:根据文字 + 图片生成长图文 + +### 目标拆解 + +- 支持大段文字生成图片 +- 支持插入图片 +- 支持上下左右边距设置 +- 支持字体选择 +- 支持字体颜色 +- 支持左对齐,居中,右对齐 + + +### 预期结果 + +我们将通过spring-boot搭建一个生成长图文的http接口,通过传入参数来指定各种配置信息,下面是一个最终调用的示意图 + +![演示图](https://static.oschina.net/uploads/img/201708/18180654_y9iv.gif "演示图") + + +## 设计&实现 + +> 长图文的生成,采用awt进行文字绘制和图片绘制 + + +### 1. 参数选项 `ImgCreateOptions` + +根据我们的预期目标,设定配置参数,基本上会包含以下参数 + +```java +@Getter +@Setter +@ToString +public class ImgCreateOptions { + + /** + * 绘制的背景图 + */ + private BufferedImage bgImg; + + + /** + * 生成图片的宽 + */ + private Integer imgW; + + + private Font font = new Font("宋体", Font.PLAIN, 18); + + /** + * 字体色 + */ + private Color fontColor = Color.BLACK; + + + /** + * 两边边距 + */ + private int leftPadding; + + /** + * 上边距 + */ + private int topPadding; + + /** + * 底边距 + */ + private int bottomPadding; + + /** + * 行距 + */ + private int linePadding; + + + private AlignStyle alignStyle; + + /** + * 对齐方式 + */ + public enum AlignStyle { + LEFT, + CENTER, + RIGHT; + + + private static Map map = new HashMap<>(); + + static { + for(AlignStyle style: AlignStyle.values()) { + map.put(style.name(), style); + } + } + + + public static AlignStyle getStyle(String name) { + name = name.toUpperCase(); + if (map.containsKey(name)) { + return map.get(name); + } + + return LEFT; + } + } +} +``` + + +### 2. 封装类 `ImageCreateWrapper` + +封装配置参数的设置,绘制文本,绘制图片的操作方式,输出样式等接口 + +```java +public class ImgCreateWrapper { + + + public static Builder build() { + return new Builder(); + } + + + public static class Builder { + /** + * 生成的图片创建参数 + */ + private ImgCreateOptions options = new ImgCreateOptions(); + + + /** + * 输出的结果 + */ + private BufferedImage result; + + + private final int addH = 1000; + + + /** + * 实际填充的内容高度 + */ + private int contentH; + + + private Color bgColor; + + public Builder setBgColor(int color) { + return setBgColor(ColorUtil.int2color(color)); + } + + /** + * 设置背景图 + * + * @param bgColor + * @return + */ + public Builder setBgColor(Color bgColor) { + this.bgColor = bgColor; + return this; + } + + + public Builder setBgImg(BufferedImage bgImg) { + options.setBgImg(bgImg); + return this; + } + + + public Builder setImgW(int w) { + options.setImgW(w); + return this; + } + + public Builder setFont(Font font) { + options.setFont(font); + return this; + } + + public Builder setFontName(String fontName) { + Font font = options.getFont(); + options.setFont(new Font(fontName, font.getStyle(), font.getSize())); + return this; + } + + + public Builder setFontColor(int fontColor) { + return setFontColor(ColorUtil.int2color(fontColor)); + } + + public Builder setFontColor(Color fontColor) { + options.setFontColor(fontColor); + return this; + } + + public Builder setFontSize(Integer fontSize) { + Font font = options.getFont(); + options.setFont(new Font(font.getName(), font.getStyle(), fontSize)); + return this; + } + + public Builder setLeftPadding(int leftPadding) { + options.setLeftPadding(leftPadding); + return this; + } + + public Builder setTopPadding(int topPadding) { + options.setTopPadding(topPadding); + contentH = topPadding; + return this; + } + + public Builder setBottomPadding(int bottomPadding) { + options.setBottomPadding(bottomPadding); + return this; + } + + public Builder setLinePadding(int linePadding) { + options.setLinePadding(linePadding); + return this; + } + + public Builder setAlignStyle(String style) { + return setAlignStyle(ImgCreateOptions.AlignStyle.getStyle(style)); + } + + public Builder setAlignStyle(ImgCreateOptions.AlignStyle alignStyle) { + options.setAlignStyle(alignStyle); + return this; + } + + + public Builder drawContent(String content) { + // xxx + return this; + } + + + public Builder drawImage(String img) { + BufferedImage bfImg; + try { + bfImg = ImageUtil.getImageByPath(img); + } catch (IOException e) { + log.error("load draw img error! img: {}, e:{}", img, e); + throw new IllegalStateException("load draw img error! img: " + img, e); + } + + return drawImage(bfImg); + } + + + public Builder drawImage(BufferedImage bufferedImage) { + + // xxx + return this; + } + + + public BufferedImage asImage() { + int realH = contentH + options.getBottomPadding(); + + BufferedImage bf = new BufferedImage(options.getImgW(), realH, BufferedImage.TYPE_INT_ARGB); + Graphics2D g2d = bf.createGraphics(); + + if (options.getBgImg() == null) { + g2d.setColor(bgColor == null ? Color.WHITE : bgColor); + g2d.fillRect(0, 0, options.getImgW(), realH); + } else { + g2d.drawImage(options.getBgImg(), 0, 0, options.getImgW(), realH, null); + } + + g2d.drawImage(result, 0, 0, null); + g2d.dispose(); + return bf; + } + + + public String asString() throws IOException { + BufferedImage img = asImage(); + return Base64Util.encode(img, "png"); + } +} +``` + + +上面具体的文本和图片绘制实现没有,后面详细讲解,这里主要关注的是一个参数 `contentH`, 表示实际绘制的内容高度(包括上边距),因此最终生成图片的高度应该是 + +`int realH = contentH + options.getBottomPadding();` + + +其次简单说一下上面的图片输出方法:`com.hust.hui.quickmedia.common.image.ImgCreateWrapper.Builder#asImage` + +- 计算最终生成图片的高度(宽度由输入参数指定) +- 绘制背景(如果没有背景图片,则用纯色填充) +- 绘制实体内容(即绘制的文本,图片) + + +### 3. 内容填充 `GraphicUtil` +> 具体的内容填充,区分为文本绘制和图片绘制 + +#### 设计 + +1. 考虑到在填充的过程中,可以自由设置字体,颜色等,所以在我们的绘制方法中,直接实现掉内容的绘制填充,即 `drawXXX` 方法真正的实现了内容填充,执行完之后,内容已经填充到画布上了 + +2. 图片绘制,考虑到图片本身大小和最终结果的大小可能有冲突,采用下面的规则 + - 绘制图片宽度 <=(指定生成图片宽 - 边距),全部填充 + - 绘制图片宽度 >(指定生成图片宽 - 边距),等比例缩放绘制图片 + +3. 文本绘制,换行的问题 + - 每一行允许的文本长度有限,超过时,需要自动换行处理 + + +#### 文本绘制 + +考虑基本的文本绘制,流程如下 + +- 创建`BufferImage`对象 +- 获取`Graphic2d`对象,操作绘制 +- 设置基本配置信息 +- 文本按换行进行拆分为字符串数组, 循环绘制单行内容 + - 计算当行字符串,实际绘制的行数,然后进行拆分 + - 依次绘制文本(需要注意y坐标的变化) + + + +下面是具体的实现 + +```java +public static int drawContent(Graphics2D g2d, + String content, + int y, + ImgCreateOptions options) { + + int w = options.getImgW(); + int leftPadding = options.getLeftPadding(); + int linePadding = options.getLinePadding(); + Font font = options.getFont(); + + + // 一行容纳的字符个数 + int lineNum = (int) Math.floor((w - (leftPadding << 1)) / (double) font.getSize()); + + // 对长串字符串进行分割成多行进行绘制 + String[] strs = splitStr(content, lineNum); + + g2d.setFont(font); + + g2d.setColor(options.getFontColor()); + int index = 0; + int x; + for (String tmp : strs) { + x = calOffsetX(leftPadding, w, tmp.length() * font.getSize(), options.getAlignStyle()); + g2d.drawString(tmp, x, y + (linePadding + font.getSize()) * index); + index++; + } + + + return y + (linePadding + font.getSize()) * (index); +} + +/** + * 计算不同对其方式时,对应的x坐标 + * + * @param padding 左右边距 + * @param width 图片总宽 + * @param strSize 字符串总长 + * @param style 对其方式 + * @return 返回计算后的x坐标 + */ +private static int calOffsetX(int padding, + int width, + int strSize, + ImgCreateOptions.AlignStyle style) { + if (style == ImgCreateOptions.AlignStyle.LEFT) { + return padding; + } else if (style == ImgCreateOptions.AlignStyle.RIGHT) { + return width - padding - strSize; + } else { + return (width - strSize) >> 1; + } +} + + +/** + * 按照长度对字符串进行分割 + *

+ * fixme 包含emoj表情时,兼容一把 + * + * @param str 原始字符串 + * @param splitLen 分割的长度 + * @return + */ +public static String[] splitStr(String str, int splitLen) { + int len = str.length(); + int size = (int) Math.ceil(len / (float) splitLen); + + String[] ans = new String[size]; + int start = 0; + int end = splitLen; + for (int i = 0; i < size; i++) { + ans[i] = str.substring(start, end > len ? len : end); + start = end; + end += splitLen; + } + + return ans; +} +``` + + +上面的实现比较清晰了,图片的绘制则更加简单 + +#### 图片绘制 + +只需要重新计算下待绘制图片的宽高即可,具体实现如下 + + +```java +/** + * 在原图上绘制图片 + * + * @param source 原图 + * @param dest 待绘制图片 + * @param y 待绘制的y坐标 + * @param options + * @return 绘制图片的高度 + */ +public static int drawImage(BufferedImage source, + BufferedImage dest, + int y, + ImgCreateOptions options) { + Graphics2D g2d = getG2d(source); + int w = Math.min(dest.getWidth(), options.getImgW() - (options.getLeftPadding() << 1)); + int h = w * dest.getHeight() / dest.getWidth(); + + int x = calOffsetX(options.getLeftPadding(), + options.getImgW(), w, options.getAlignStyle()); + + // 绘制图片 + g2d.drawImage(dest, + x, + y + options.getLinePadding(), + w, + h, + null); + g2d.dispose(); + + return h; +} + +public static Graphics2D getG2d(BufferedImage bf) { + Graphics2D g2d = bf.createGraphics(); + + g2d.setRenderingHint(RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY); + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2d.setRenderingHint(RenderingHints.KEY_COLOR_RENDERING, RenderingHints.VALUE_COLOR_RENDER_QUALITY); + g2d.setRenderingHint(RenderingHints.KEY_DITHERING, RenderingHints.VALUE_DITHER_ENABLE); + g2d.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON); + g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + g2d.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE); + + return g2d; +} +``` + + +### 4. 内容渲染 +前面只是给出了单块内容(如一段文字,一张图片)的渲染,存在一些问题 + +- 绘制的内容超过画布的高度如何处理 +- 文本绘制要求传入的文本没有换行符,否则换行不生效 +- 交叉绘制的场景,如何重新计算y坐标 + +--- + +解决这些问题则是在 `ImgCreateWrapper` 的具体绘制中进行了实现,先看文本的绘制 + +- 根据换行符对字符串进行拆分 +- 计算绘制内容最终转换为图片时,所占用的高度 +- 重新生成画布 `BufferedImage result` + - 如果result为空,则直接生成 + - 如果最终生成的高度,超过已有画布的高度,则生成一个更高的画布,并将原来的内容绘制上去 +- 迭代绘制单行内容 + +```java +public Builder drawContent(String content) { + String[] strs = StringUtils.split(content, "\n"); + if (strs.length == 0) { // empty line + strs = new String[1]; + strs[0] = " "; + } + + int fontSize = options.getFont().getSize(); + int lineNum = calLineNum(strs, options.getImgW(), options.getLeftPadding(), fontSize); + // 填写内容需要占用的高度 + int height = lineNum * (fontSize + options.getLinePadding()); + + if (result == null) { + result = GraphicUtil.createImg(options.getImgW(), + Math.max(height + options.getTopPadding() + options.getBottomPadding(), BASE_ADD_H), + null); + } else if (result.getHeight() < contentH + height + options.getBottomPadding()) { + // 超过原来图片高度的上限, 则需要扩充图片长度 + result = GraphicUtil.createImg(options.getImgW(), + result.getHeight() + Math.max(height + options.getBottomPadding(), BASE_ADD_H), + result); + } + + + // 绘制文字 + Graphics2D g2d = GraphicUtil.getG2d(result); + int index = 0; + for (String str : strs) { + GraphicUtil.drawContent(g2d, str, + contentH + (fontSize + options.getLinePadding()) * (++index) + , options); + } + g2d.dispose(); + + contentH += height; + return this; +} + + +/** + * 计算总行数 + * + * @param strs 字符串列表 + * @param w 生成图片的宽 + * @param padding 渲染内容的左右边距 + * @param fontSize 字体大小 + * @return + */ +private int calLineNum(String[] strs, int w, int padding, int fontSize) { + // 每行的字符数 + double lineFontLen = Math.floor((w - (padding << 1)) / (double) fontSize); + + + int totalLine = 0; + for (String str : strs) { + totalLine += Math.ceil(str.length() / lineFontLen); + } + + return totalLine; +} +``` + + +上面需要注意的是画布的生成规则,特别是高度超过上限之后,重新计算图片高度时,需要额外注意新增的高度,应该为基本的增量与(绘制内容高度+下边距)的较大值 + +``` +int realAddH = Math.max(bufferedImage.getHeight() + options.getBottomPadding() + options.getTopPadding(), BASE_ADD_H) +``` + + + +重新生成画布实现 `com.hust.hui.quickmedia.common.util.GraphicUtil#createImg` + +```java +public static BufferedImage createImg(int w, int h, BufferedImage img) { + BufferedImage bf = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + Graphics2D g2d = bf.createGraphics(); + + if (img != null) { + g2d.setComposite(AlphaComposite.Src); + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2d.drawImage(img, 0, 0, null); + } + g2d.dispose(); + return bf; +} +``` + + +上面理解之后,绘制图片就比较简单了,基本上行没什么差别 + + +```java +public Builder drawImage(String img) { + BufferedImage bfImg; + try { + bfImg = ImageUtil.getImageByPath(img); + } catch (IOException e) { + log.error("load draw img error! img: {}, e:{}", img, e); + throw new IllegalStateException("load draw img error! img: " + img, e); + } + + return drawImage(bfImg); +} + + +public Builder drawImage(BufferedImage bufferedImage) { + + if (result == null) { + result = GraphicUtil.createImg(options.getImgW(), + Math.max(bufferedImage.getHeight() + options.getBottomPadding() + options.getTopPadding(), BASE_ADD_H), + null); + } else if (result.getHeight() < contentH + bufferedImage.getHeight() + options.getBottomPadding()) { + // 超过阀值 + result = GraphicUtil.createImg(options.getImgW(), + result.getHeight() + Math.max(bufferedImage.getHeight() + options.getBottomPadding() + options.getTopPadding(), BASE_ADD_H), + result); + } + + // 更新实际高度 + int h = GraphicUtil.drawImage(result, + bufferedImage, + contentH, + options); + contentH += h + options.getLinePadding(); + return this; +} +``` + +### 5. http接口 + +上面实现的生成图片的公共方法,在 `quick-media` 工程中,利用spring-boot搭建了一个web服务,提供了一个http接口,用于生成长图文,最终的成果就是我们开头的那个gif图的效果,相关代码就没啥好说的,有兴趣的可以直接查看工程源码,链接看最后 + +## 测试验证 + +上面基本上完成了我们预期的目标,接下来则是进行验证,测试代码比较简单,先准备一段文本,这里拉了一首诗 + +```text +招魂酹翁宾旸 +郑起 + +君之在世帝敕下,君之谢世帝敕回。 +魂之为变性原返,气之为物情本开。 +於戏龙兮凤兮神气盛,噫嘻鬼兮归兮大块埃。 +身可朽名不可朽,骨可灰神不可灰。 +采石捉月李白非醉,耒阳避水子美非灾。 +长孙王吉命不夭,玉川老子诗不徘。 +新城罗隐在奇特,钱塘潘阆终崔嵬。 +阴兮魄兮曷往,阳兮魄兮曷来。 +君其归来,故交寥落更散漫。 +君来归来,帝城绚烂可徘徊。 +君其归来,东西南北不可去。 +君其归来。 +春秋霜露令人哀。 +花之明吾无与笑,叶之陨吾实若摧。 +晓猿啸吾闻泪堕,宵鹤立吾见心猜。 +玉泉其清可鉴,西湖其甘可杯。 +孤山暖梅香可嗅,花翁葬荐菊之隈。 +君其归来,可伴逋仙之梅,去此又奚之哉。 +``` + +测试代码 + +```java +@Test +public void testGenImg() throws IOException { + int w = 400; + int leftPadding = 10; + int topPadding = 40; + int bottomPadding = 40; + int linePadding = 10; + Font font = new Font("宋体", Font.PLAIN, 18); + + ImgCreateWrapper.Builder build = ImgCreateWrapper.build() + .setImgW(w) + .setLeftPadding(leftPadding) + .setTopPadding(topPadding) + .setBottomPadding(bottomPadding) + .setLinePadding(linePadding) + .setFont(font) + .setAlignStyle(ImgCreateOptions.AlignStyle.CENTER) +// .setBgImg(ImageUtil.getImageByPath("qrbg.jpg")) + .setBgColor(0xFFF7EED6) + ; + + + BufferedReader reader = FileReadUtil.createLineRead("text/poem.txt"); + String line; + int index = 0; + while ((line = reader.readLine()) != null) { + build.drawContent(line); + + if (++index == 5) { + build.drawImage(ImageUtil.getImageByPath("https://static.oschina.net/uploads/img/201708/12175633_sOfz.png")); + } + + if (index == 7) { + build.setFontSize(25); + } + + if (index == 10) { + build.setFontSize(20); + build.setFontColor(Color.RED); + } + } + + BufferedImage img = build.asImage(); + String out = Base64Util.encode(img, "png"); + System.out.println(""); +} +``` + + +输出图片 + + +![测试结果图](https://static.oschina.net/uploads/img/201708/18180717_MrRM.png "测试结果图") + +## 其他 + + +项目地址: [https://github.com/liuyueyi/quick-media](https://github.com/liuyueyi/quick-media) + + +个人博客:[一灰的个人博客](http://blog.zbang.online:8080) + +公众号获取更多: + +![个人信息](https://static.oschina.net/uploads/img/201708/12175649_wn2r.png "个人信息") + + + diff --git "a/docs/\346\217\222\344\273\266/image/\345\233\276\347\211\207\345\220\210\346\210\220.md" "b/docs/\346\217\222\344\273\266/image/\345\233\276\347\211\207\345\220\210\346\210\220.md" new file mode 100644 index 00000000..8e4947ac --- /dev/null +++ "b/docs/\346\217\222\344\273\266/image/\345\233\276\347\211\207\345\220\210\346\210\220.md" @@ -0,0 +1,424 @@ +# 图片合成 + +> 利用Java的绘图方法,实现图片合成 + +在开始之前,先定一个小目标,我们希望通过图片合成的方式,创建一个类似下面样式的图片 + +![](https://static.oschina.net/uploads/img/201710/13203703_6IVg.jpg) + +### I. 设计思路 +> 首先解析一下我们的目标实现图片合成,那么这些合成的基本组成单元有些什么? + +**组成基本单元** + +- 图片 +- 文字 +- 几何图形 + +也就是说,我们可以将任意个图片,文字,几何图形,按照自己的意愿进行拼接,那么问题就转变成两个 + +- 基本单元如何在画布上渲染 +- 基本单元之间如何配合使用 + +### II. 基本单元绘制 + +首先定义一个基本单元的接口,之后所有组合的元素都继承自这个接口 + +接口`IMergeCell`只定义一个绘制的方法,用于实现该基本单元的绘制方式 + +```java +public interface IMergeCell { + void draw(Graphics2D g2d); +} +``` + +#### 1. 图片绘制 + +绘制图片,一般来讲需要知道: + +- 绘制的坐标(x,y) +- 绘制图片的宽高(w,h),当目标是绘制原图时,宽高一般为图片本身的宽高 + +结合上面两点,图片组成单元的定义如下: `ImgCell` + +```java +@Data +@Builder +public class ImgCell implements IMergeCell { + + private BufferedImage img; + + private Integer x, y, w, h; + + @Override + public void draw(Graphics2D g2d) { + if (w == null) { + w = img.getWidth(); + } + + if (h == null) { + h = img.getHeight(); + } + + g2d.drawImage(img, x, y, w, h, null); + } +} +``` + +#### 2. 文本绘制 +> 图片绘制比较简单,相比而言,文字绘制就麻烦一点,主要是文本绘制的对齐方式,竖排还是横排布局 + +**首先分析我们需要的基本信息** + +- 考虑对齐方式(居中对齐,靠左,靠上,靠右,靠下) + - 因此需要确定文本绘制的区域,所以需要两个坐标 (startX, startY), (endX, endY) + +- 文本绘制参数 + - 可以指定字体`Font`,文本颜色 `Color`,行间距 `lineSpace` + +- 绘制的文本信息 + - 文本内容 `List` + +**绘制实现** + +- 若单行的文本超过长度上限,则需要自动换行,所以有 `batchSplitText` 方法,对原文本内容进行分割,确保不会超过边界 + +- 不同的对齐方式,绘制的起始坐标需要计算, 所以在水平布局文字时,需要通过 `calculateX`方法获取新的x坐标;竖直布局文字时,需要通过 `calculateY`获取新的y坐标 + + +实际代码如下 + +```java +@Data +public class TextCell implements IMergeCell { + + private List texts; + + private Color color = Color.black; + + private Font font = FontUtil.DEFAULT_FONT; + + + private int lineSpace; + + private int startX, startY; + private int endX, endY; + + + /** + * 绘制样式 + */ + private ImgCreateOptions.DrawStyle drawStyle = ImgCreateOptions.DrawStyle.HORIZONTAL; + + + private ImgCreateOptions.AlignStyle alignStyle = ImgCreateOptions.AlignStyle.LEFT; + + + public void addText(String text) { + if (texts == null) { + texts = new ArrayList<>(); + } + + texts.add(text); + } + + + @Override + public void draw(Graphics2D g2d) { + g2d.setColor(color); + g2d.setFont(font); + + FontMetrics fontMetrics = FontUtil.getFontMetric(font); + int tmpHeight = fontMetrics.getHeight(), tmpW = font.getSize() >>> 1; + int tmpY = startY, tmpX = startX; + int offsetX = drawStyle == ImgCreateOptions.DrawStyle.VERTICAL_LEFT + ? (font.getSize() + fontMetrics.getDescent() + lineSpace) + : -(font.getSize() + fontMetrics.getDescent() + lineSpace); + // 单行文本自动换行分割 + List splitText = batchSplitText(texts, fontMetrics); + for (String info : splitText) { + if (drawStyle == ImgCreateOptions.DrawStyle.HORIZONTAL) { + g2d.drawString(info, calculateX(info, fontMetrics), tmpY); + + // 换行,y坐标递增一位 + tmpY += fontMetrics.getHeight() + lineSpace; + } else { // 垂直绘制文本 + char[] chars = info.toCharArray(); + + tmpY = calculateY(info, fontMetrics); + for (int i = 0; i < chars.length; i++) { + tmpX = PunctuationUtil.isPunctuation(chars[i]) ? tmpW : 0; + g2d.drawString(chars[i] + "", + tmpX + (PunctuationUtil.isPunctuation(chars[i]) ? tmpW : 0), + tmpY); + tmpY += tmpHeight; + } + + // 换一列 + tmpX += offsetX; + } + } + } + + + // 若单行文本超过长度限制,则自动进行换行 + private List batchSplitText(List texts, FontMetrics fontMetrics) { + List ans = new ArrayList<>(); + if (drawStyle == ImgCreateOptions.DrawStyle.HORIZONTAL) { + int lineLen = Math.abs(endX - startX); + for(String t: texts) { + ans.addAll(Arrays.asList(GraphicUtil.splitStr(t, lineLen, fontMetrics))); + } + } else { + int lineLen = Math.abs(endY - startY); + for(String t: texts) { + ans.addAll(Arrays.asList(GraphicUtil.splitVerticalStr(t, lineLen, fontMetrics))); + } + } + return ans; + } + + + private int calculateX(String text, FontMetrics fontMetrics) { + if (alignStyle == ImgCreateOptions.AlignStyle.LEFT) { + return startX; + } else if (alignStyle == ImgCreateOptions.AlignStyle.RIGHT) { + return endX - fontMetrics.stringWidth(text); + } else { + return startX + ((endX - startX - fontMetrics.stringWidth(text)) >>> 1); + } + + } + + + private int calculateY(String text, FontMetrics fontMetrics) { + if (alignStyle == ImgCreateOptions.AlignStyle.TOP) { + return startY; + } else if (alignStyle == ImgCreateOptions.AlignStyle.BOTTOM) { + int size = fontMetrics.stringWidth(text) + fontMetrics.getDescent() * (text.length() - 1); + return endY - size; + } else { + int size = fontMetrics.stringWidth(text) + fontMetrics.getDescent() * (text.length() - 1); + return startY + ((endY - endX - size) >>> 1); + } + } +} +``` + +_说明:_ + +- 单行文本的分割,使用了博文系列中的工具方法 `GraphicUtil.splitStr`,有兴趣的关注源码进行查看 +- 水平布局时,期望 `startX < endX`, 从习惯来讲,基本上我们都是从左到右进行阅读 +- 水平or垂直布局,都希望是 `startY < endY` +- 垂直布局时,以字符为单位进行绘制;标点符号的绘制时,x坐标有一个偏移量 + + +#### 3. Line直线绘制 + +几何图形之直线绘制,给出起点和结束点坐标,绘制一条直线,比较简单;这里给出了虚线的支持 + + +```java +@Data +@Builder +public class LineCell implements IMergeCell { + + /** + * 起点坐标 + */ + private int x1, y1; + + /** + * 终点坐标 + */ + private int x2, y2; + + /** + * 颜色 + */ + private Color color; + + + /** + * 是否是虚线 + */ + private boolean dashed; + + /** + * 虚线样式 + */ + private Stroke stroke = CellConstants.LINE_DEFAULT_STROKE; + + + @Override + public void draw(Graphics2D g2d) { + g2d.setColor(color); + if (!dashed) { + g2d.drawLine(x1, y1, x2, y2); + } else { // 绘制虚线时,需要保存下原有的画笔用于恢复 + Stroke origin = g2d.getStroke(); + g2d.setStroke(stroke); + g2d.drawLine(x1, y1, x2, y2); + g2d.setStroke(origin); + } + } +} +``` + +#### 4. 矩形框绘制 + +矩形框绘制,同直线绘制,支持圆角矩形,支持虚线框 + +```java +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class RectCell implements IMergeCell { + + /** + * 起始坐标 + */ + private int x, y; + + /** + * 矩形宽高 + */ + private int w, h; + + + /** + * 颜色 + */ + private Color color; + + + /** + * 是否为虚线 + */ + private boolean dashed; + + + /** + * 虚线样式 + */ + private Stroke stroke; + + + /** + * 圆角弧度 + */ + private int radius; + + + @Override + public void draw(Graphics2D g2d) { + g2d.setColor(color); + if (!dashed) { + g2d.drawRoundRect(x, y, w, h, radius, radius); + } else { + Stroke stroke = g2d.getStroke(); + g2d.setStroke(stroke); + g2d.drawRoundRect(x, y, w, h, radius, radius); + g2d.setStroke(stroke); + } + } +} +``` + +#### 5. 矩形区域填充 + +```java +@Data +@Builder +public class RectFillCell implements IMergeCell { + + private Font font; + + private Color color; + + + private int x,y,w,h; + + @Override + public void draw(Graphics2D g2d) { + g2d.setFont(font); + g2d.setColor(color);; + g2d.fillRect(x, y, w, h); + } +} +``` + +### III. 封装 + +上面实现了几个常见的基本单元绘制,接下来则是封装绘制, 这块的逻辑就比较简单了如下 + +```java +public class ImgMergeWrapper { + public static BufferedImage merge(List list, int w, int h) { + BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + Graphics2D g2d = GraphicUtil.getG2d(img); + list.forEach(cell -> cell.draw(g2d)); + return img; + } +} +``` + + +### IV. 测试 + +写了一个模板`QrCodeCardTemplateBuilder`,用于拼装上图的样式,代码较长,不贴了,有兴趣的查看原图 + +测试代码如下 + +```java +@Test +public void testTemplate() throws IOException { + BufferedImage logo = ImageUtil.getImageByPath("logo.jpg"); + BufferedImage qrCode = ImageUtil.getImageByPath("/Users/yihui/Desktop/12.jpg"); + String name = "小灰灰blog"; + List desc = Arrays.asList("我是一灰灰,一匹不吃羊的狼 专注码农技术分享"); + + + int w = QrCodeCardTemplate.w, h = QrCodeCardTemplate.h; + List list = QrCodeCardTemplateBuilder.build(logo, name, desc, qrCode, "微 信 公 众 号"); + + BufferedImage bg = ImgMergeWrapper.merge(list, w, h); + + try { + ImageIO.write(bg, "jpg", new File("/Users/yihui/Desktop/merge.jpg")); + } catch (Exception e) { + e.printStackTrace(); + } +} +``` + +演示图如下: + + +![](https://static.oschina.net/uploads/img/201710/13203703_6IVg.jpg) + + +### V. 其他 + +**项目地址:** + +- [https://github.com/liuyueyi/quick-media](https://github.com/liuyueyi/quick-media) +- `QuickMedia` 目标是创建一个专注图文,音视频,二维码处理的开源项目 + + +**系列博文** + +- [spring-boot & ffmpeg 搭建一个音频转码服务](https://my.oschina.net/u/566591/blog/1359432) +- [spring-boot & zxingy 搭建二维码服务](https://my.oschina.net/u/566591/blog/1457164) +- [二维码服务拓展(支持logo,圆角logo,背景图,颜色配置)](https://my.oschina.net/u/566591/blog/1491697) +- [zxing二维码生成服务之深度定制](https://my.oschina.net/u/566591/blog/1507162) +- [Java实现长图文生成](https://my.oschina.net/u/566591/blog/1514644) +- [Java竖排长图文生成](https://my.oschina.net/u/566591/blog/1529564) +- [Java实现markdown 转 html](https://my.oschina.net/u/566591/blog/1535380) +- [Java实现html 转 image](https://my.oschina.net/u/566591/blog/1536078) + + +**扫描关注,java分享** + +![](https://static.oschina.net/uploads/img/201710/13203703_6IVg.jpg) diff --git "a/docs/\346\217\222\344\273\266/image/\346\246\202\350\247\210.md" "b/docs/\346\217\222\344\273\266/image/\346\246\202\350\247\210.md" new file mode 100644 index 00000000..3bf12917 --- /dev/null +++ "b/docs/\346\217\222\344\273\266/image/\346\246\202\350\247\210.md" @@ -0,0 +1 @@ +# 概览 \ No newline at end of file diff --git "a/docs/\346\217\222\344\273\266/magic/Java\345\200\237\345\212\251ImageMagic\345\256\236\347\216\260\345\233\276\347\211\207\347\274\226\350\276\221\346\234\215\345\212\241.md" "b/docs/\346\217\222\344\273\266/magic/Java\345\200\237\345\212\251ImageMagic\345\256\236\347\216\260\345\233\276\347\211\207\347\274\226\350\276\221\346\234\215\345\212\241.md" new file mode 100644 index 00000000..ad5103ec --- /dev/null +++ "b/docs/\346\217\222\344\273\266/magic/Java\345\200\237\345\212\251ImageMagic\345\256\236\347\216\260\345\233\276\347\211\207\347\274\226\350\276\221\346\234\215\345\212\241.md" @@ -0,0 +1,971 @@ +# Java 借助ImageMagic实现图片编辑服务 + +java原生对于图片的编辑处理并没有特别友好,而且问题也有不少,那么作为一个java后端,如果要提供图片的编辑服务可以怎么办?也得想办法去支持业务需求,本片博文基于此进行展开 + + + +## I. 调研 + +首先最容易想到的就是目前是不是已经有了相关的开源库,直接用不就很high了嘛,git上搜一下 + +### 1. thumbnailator + +差不多四年都没有更新了,基于awt进行图片的编辑处理,目前提供了基本的图片编辑接口,开始用了一段时间,有几个绕不够去的坑,所以最后放弃了 + +使用姿势: + +```xml + + net.coobird + thumbnailator + 0.4.8 + +``` + +一个使用case: + +```java +BufferedImage originalImage = ImageIO.read(new File("original.jpg")); + +BufferedImage thumbnail = Thumbnails.of(originalImage) + .size(200, 200) + .rotate(90) + .asBufferedImage(); +``` + +问题说明: + +- jpg图片编辑后,输出图片变红的问题(详情参考:[兼容ImageIO读取jpeg图片变红](https://liuyueyi.github.io/hexblog/2018/01/22/%E5%85%BC%E5%AE%B9ImageIO%E8%AF%BB%E5%8F%96jpeg%E5%9B%BE%E7%89%87%E5%8F%98%E7%BA%A2/)) +- 图片精度丢失(对于精度要求较高的场景下,直接使用Jdk的BufferedImage会丢失精度) + + +上面两个问题中,第二个精度丢失在某些对图片质量有要求的场景下比较严重,如果业务场景没那么将就的话,用这个库还是可以减少很多事情的,下面基于ImageMagic的接口设计,很大程度上参考了该工程的使用规范,因为使用起来(+阅读)确实特别顺畅 + + +### 2. simpleimage + +阿里的开源库,文档极其欠缺,而且良久没有人维护,没有实际使用过,感觉属于玩票的性质(个人猜测是KPI为导向下的产物) + + +如果想造轮子的话,参考它的源码,某些图片的处理方案还是不错的 + +### 3. imagemagic + im4java + +ImageMagic/GraphicMagic 是c++的图象处理软件,很多服务基于此来搭建图片处理服务的 + +- 优点:稳定、性能高、支持接口多、开箱即用、靠谱 +- 缺点:得提前配置环境,基本上改造不动,内部有问题也没辙 + +这个方法也是下面的主要讲述重点,放弃Thumbnailator选择imagemagic的原因如下: + +- 支持更多的服务功能(比Thumbnailator多很多的接口) +- 没有精度丢失问题 +- 没有图片失真问题(颜色变化,alpha值变化问题) + +## II. 环境准备 + +首先得安装ImageMagic环境,有不少的第三方依赖,下面提供linux和mac的安装过程 + +### 1. linux安装过程 + + +```sh +# 依赖安装 +yum install libjpeg-devel +yum install libpng-devel +yum install libwebp-devel + + +## 也可以使用源码方式安装 +安装jpeg 包 `wget ftp://223.202.54.10/pub/web/php/libjpeg-6b.tar.gz` +安装webp 包 `wget http://www.imagemagick.org/download/delegates/libwebp-0.5.1.tar.gz` +安装png 包 `wget http://www.imagemagick.org/download/delegates/libpng-1.6.24.tar.gz` + + +## 下载并安装ImageMagic +wget http://www.imagemagick.org/download/ImageMagick.tar.gz + +tar -zxvf ImageMagick.tar.gz +cd ImageMagick-7.0.7-28 +./configure; sudo make; sudo make install +``` + +安装完毕之后,进行测试 + +```sh +$ convert --version + +Version: ImageMagick 7.0.7-28 Q16 x86_64 2018-04-17 http://www.imagemagick.org +Copyright: © 1999-2018 ImageMagick Studio LLC +License: http://www.imagemagick.org/script/license.php +Features: Cipher DPC HDRI OpenMP +Delegates (built-in): fontconfig freetype jng jpeg lzma png webp x xml zlib +``` + + +### 2. mac安装过程 + +依赖安装 + +```sh +sudo brew install jpeg +sudo brew install libpng +sudo brew install libwebp +sudo brew install GraphicsMagick +sudo brew install ImageMagick +``` + +源码安装方式与上面一致 + + +### 3. 问题及修复 + +如果安装完毕之后,可能会出现下面的问题 + +提示找不到png依赖: + +- 安装:一直找不到 png的依赖,查阅需要安装 http://pkgconfig.freedesktop.org/releases/pkg-config-0.28.tar.gz + +执行 convert 提示linux shared libraries 不包含某个库 + +- 临时方案:`export LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH` +- 永久方案: + + ```sh + vi /etc/ld.so.conf + 在这个文件里加入:/usr/local/lib 来指明共享库的搜索位置 + 然后再执行/sbin/ldconf + ``` + +### 4. 常见Convert命令 + +imagemagic的场景使用命令如下 + +裁图 + +- convert test.jpg -crop 640x960+0+0 output.jpg + +旋转 + +- convert test.jpg -rotate 90 output.jpg + +缩放 + +- convert test.jpg -resize 200x200 output.jpg + +强制宽高缩放 + +- convert test.jpg -resize 200x200! output.jpg + +缩略图 + +- convert -thumbnail 200x300 test.jpg thumb.jpg + +上下翻转: + +- convert -flip foo.png bar.png + +左右翻转: + +- convert -flop foo.png bar.png + +水印: + +- composite -gravity northwest -dissolve 100 -geometry +0+0 water.png temp.jpg out.jpg + +添加边框 : + +- convert -border 6x6 -bordercolor "#ffffff" test.jpg bord.jpg + +去除边框 : + +- convert -thumbnail 200x300 test.jpg thumb.jpg + +## III. 接口设计与实现 + +java调用ImageMagic的方式有两种,一个是基于命令行的,一种是基于JNI的,我们选则im4java来操作imagemagic的接口(基于命令行的操作) + +**目标:** + +对外的使用姿势尽可能如 `Thumbnailtor`,采用builder模式来设置参数,支持多种输入输出 + + +### 1. im4java使用姿势 + +几个简单的case,演示下如何使用im4java实现图片的操作 + +```java +IMOperation op = new IMOperation(); + +// 裁剪 +op.crop(operate.getWidth(), operate.getHeight(), operate.getX(), operate.getY()); + + +// 旋转 +op.rotate(rotate); + + +// 压缩 +op.resize(operate.getWidth(), operate.getHeight()); +op.quality(operate.getQuality().doubleValue()); // 精度 + + +// 翻转 +op.flip(); + +// 镜像 +op.flop(); + +// 水印 +op.geometry(operate.getWidth(), operate.getHeight(), operate.getX(), operate.getY()).composite(); + +// 边框 +op.border(operate.getWidth(), operate.getHeight()).bordercolor(operate.getColor()); + + +// 原始命令方式添加 +op.addRawArgs("-resize", "!100x200"); + + +// 添加原始图片地址 +op.addImage(sourceFilename); +// 目标图片地址 +op.addImage(outputFilename); + + +/** 传true到构造函数中,则表示使用GraphicMagic, 裁图时,图片大小会变 */ +ConvertCmd convert = new ConvertCmd(); +convert.run(op); +``` + +### 2. 使用姿势 + +在具体的设计接口之前,不妨先看一下最终的使用姿势,然后逆向的再看是如何设计的 + +```java +private static final String localFile = "blogInfoV2.png"; + + +/** + * 复合操作 + */ +@Test +public void testOperate() { + BufferedImage img; + try { + img = ImgWrapper.of(localFile) + .board(10, 10, "red") + .flip() + .rotate(180) + .crop(0, 0, 1200, 500) + .asImg(); + System.out.println("--- " + img); + } catch (Exception e) { + e.printStackTrace(); + } +} +``` + +上面这个方法,演示了图片的多个操作,首先是加个红色边框,然后翻转,然后旋转180°,再裁剪输出图片 + + +所以这个封装,肯定是使用了Builder模式了,接下来看下配置参数 + + +### 3. 接口设计 + +首先确定目前支持的几个方法:`OperateType` + +其次就是相关的配置参数: `Operate` + +```java +@Data +public static class Operate { + /** + * 操作类型 + */ + private OperateType operateType; + + /** + * 裁剪宽; 缩放宽 + */ + private Integer width; + /** + * 高 + */ + private Integer height; + /** + * 裁剪时,起始 x + */ + private Integer x; + /** + * 裁剪时,起始y + */ + private Integer y; + /** + * 旋转角度 + */ + private Double rotate; + + /** + * 按照整体的缩放参数, 1 表示不变, 和裁剪一起使用 + */ + private Double radio; + + /** + * 图片精度, 1 - 100 + */ + private Integer quality; + + /** + * 颜色 (添加边框中的颜色; 去除图片中某颜色) + */ + private String color; + + /** + * 水印图片, 可以为图片名, uri, 或者inputstream + */ + private T water; + + /** + * 水印图片的类型 + */ + private String waterImgType; + + /** + * 强制按照给定的参数进行压缩 + */ + private boolean forceScale; + + + public boolean valid() { + switch (operateType) { + case CROP: + return width != null && height != null && x != null && y != null; + case SCALE: + return width != null || height != null || radio != null; + case ROTATE: + return rotate != null; + case WATER: + // 暂时不支持水印操作 + return water != null; + case BOARD: + if (width == null) { + width = 3; + } + if (height == null) { + height = 3; + } + if (color == null) { + color = "#ffffff"; + } + case FLIP: + case FLOP: + return true; + default: + return false; + } + } + + /** + * 获取水印图片的路径 + * + * @return + */ + public String getWaterFilename() throws ImgOperateException { + try { + return FileWriteUtil.saveFile(water, waterImgType).getAbsFile(); + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } +} + + +public enum OperateType { + /** + * 裁剪 + */ + CROP, + /** + * 缩放 + */ + SCALE, + /** + * 旋转 + */ + ROTATE, + /** + * 水印 + */ + WATER, + + /** + * 上下翻转 + */ + FLIP, + + /** + * 水平翻转 + */ + FLOP, + /** + * 添加边框 + */ + BOARD; +} +``` + +### 4. Builder实现 + +简化使用成本,因此针对图片裁剪、旋转等接口,封装了更友好的接口方式 + +```java +public static class Builder { + private T sourceFile; + + /** + * 图片类型 JPEG, PNG, GIF ... + *

+ * 默认为jpg图片 + */ + private String outputFormat = "jpg"; + + private List operates = new ArrayList<>(); + + public Builder(T sourceFile) { + this.sourceFile = sourceFile; + } + + + private static Builder ofString(String str) { + return new Builder(ImgWrapper.class.getClassLoader().getResource(str).getFile()); + } + + + private static Builder ofUrl(URI url) { + return new Builder(url); + } + + private static Builder ofStream(InputStream stream) { + return new Builder(stream); + } + + + /** + * 设置输出的文件格式 + * + * @param format + * @return + */ + public Builder setOutputFormat(String format) { + this.outputFormat = format; + return this; + } + + + private void updateOutputFormat(String originType) { + if (this.outputFormat != null || originType == null) { + return; + } + + int index = originType.lastIndexOf("."); + if (index <= 0) { + return; + } + this.outputFormat = originType.substring(index + 1); + } + + /** + * 缩放 + * + * @param width + * @param height + * @return + */ + public Builder scale(Integer width, Integer height, Integer quality) { + return scale(width, height, quality, false); + } + + + public Builder scale(Integer width, Integer height, Integer quality, boolean forceScale) { + Operate operate = new Operate(); + operate.setOperateType(OperateType.SCALE); + operate.setWidth(width); + operate.setHeight(height); + operate.setQuality(quality); + operate.setForceScale(forceScale); + operates.add(operate); + return this; + } + + /** + * 按照比例进行缩放 + * + * @param radio 1.0 表示不缩放, 0.5 缩放为一半 + * @return + */ + public Builder scale(Double radio, Integer quality) { + Operate operate = new Operate(); + operate.setOperateType(OperateType.SCALE); + operate.setRadio(radio); + operate.setQuality(quality); + operates.add(operate); + return this; + } + + + /** + * 裁剪 + * + * @param x + * @param y + * @param width + * @param height + * @return + */ + public Builder crop(int x, int y, int width, int height) { + Operate operate = new Operate(); + operate.setOperateType(OperateType.CROP); + operate.setWidth(width); + operate.setHeight(height); + operate.setX(x); + operate.setY(y); + operates.add(operate); + return this; + } + + + /** + * 旋转 + * + * @param rotate + * @return + */ + public Builder rotate(double rotate) { + Operate operate = new Operate(); + operate.setOperateType(OperateType.ROTATE); + operate.setRotate(rotate); + operates.add(operate); + return this; + } + + /** + * 上下翻转 + * + * @return + */ + public Builder flip() { + Operate operate = new Operate(); + operate.setOperateType(OperateType.FLIP); + operates.add(operate); + return this; + } + + /** + * 左右翻转,即镜像 + * + * @return + */ + public Builder flop() { + Operate operate = new Operate(); + operate.setOperateType(OperateType.FLOP); + operates.add(operate); + return this; + } + + /** + * 添加边框 + * + * @param width 边框的宽 + * @param height 边框的高 + * @param color 边框的填充色 + * @return + */ + public Builder board(Integer width, Integer height, String color) { + Operate args = new Operate(); + args.setOperateType(OperateType.BOARD); + args.setWidth(width); + args.setHeight(height); + args.setColor(color); + operates.add(args); + return this; + } + + /** + * 添加水印 + * + * @param water 水印的源图片 (默认为png格式) + * @param x 添加到目标图片的x坐标 + * @param y 添加到目标图片的y坐标 + * @param + * @return + */ + public Builder water(U water, int x, int y) { + return water(water, "png", x, y); + } + + /** + * 添加水印 + * + * @param water + * @param imgType 水印图片的类型; 当传入的为inputStream时, 此参数才有意义 + * @param x + * @param y + * @param + * @return + */ + public Builder water(U water, String imgType, int x, int y) { + Operate operate = new Operate<>(); + operate.setOperateType(OperateType.WATER); + operate.setX(x); + operate.setY(y); + operate.setWater(water); + operate.setWaterImgType(imgType); + operates.add(operate); + return this; + } + + + /** + * 执行图片处理, 并保存文件为: 源文件_out.jpg (类型由输出的图片类型决定) + * + * @return 保存的文件名 + * @throws Exception + */ + public String toFile() throws Exception { + return toFile(null); + } + + + /** + * 执行图片处理,并将结果保存为指定文件名的file + * + * @param outputFilename 若为null, 则输出文件为 源文件_out.jpg 这种格式 + * @return + * @throws Exception + */ + public String toFile(String outputFilename) throws Exception { + if (CollectionUtils.isEmpty(operates)) { + throw new ImgOperateException("operates null!"); + } + + /** + * 获取原始的图片信息, 并构建输出文件名 + * 1. 远程图片,则保存到临时目录下 + * 2. stream, 保存到临时目录下 + * 3. 本地文件 + * + * 输出文件都放在临时文件夹内,和原文件同名,加一个_out进行区分 + **/ + FileWriteUtil.FileInfo sourceFile = createFile(); + if (outputFilename == null) { + outputFilename = FileWriteUtil.getTmpPath() + "/" + + sourceFile.getFilename() + "_" + + System.currentTimeMillis() + "_out." + outputFormat; + } + + /** 执行图片的操作 */ + if (ImgBaseOperate.operate(operates, sourceFile.getAbsFile(), outputFilename)) { + return outputFilename; + } else { + return null; + } + } + + /** + * 执行图片操作,并输出字节流 + * + * @return + * @throws Exception + */ + public InputStream asStream() throws Exception { + if (CollectionUtils.isEmpty(operates)) { + throw new ImgOperateException("operate null!"); + } + + String outputFilename = this.toFile(); + if (StringUtils.isBlank(outputFilename)) { + return null; + } + + return new FileInputStream(new File(outputFilename)); + } + + + public byte[] asBytes() throws Exception { + if (CollectionUtils.isEmpty(operates)) { + throw new ImgOperateException("operate null!"); + } + + String outputFilename = this.toFile(); + if (StringUtils.isBlank(outputFilename)) { + return null; + } + + + return BytesTool.file2bytes(outputFilename); + } + + + public BufferedImage asImg() throws Exception { + if (CollectionUtils.isEmpty(operates)) { + throw new ImgOperateException("operate null!"); + } + + String outputFilename = this.toFile(); + if (StringUtils.isBlank(outputFilename)) { + return null; + } + + return ImageIO.read(new File(outputFilename)); + } + + + private FileWriteUtil.FileInfo createFile() throws Exception { + if (this.sourceFile instanceof String) { + /** 生成的文件在源文件目录下 */ + updateOutputFormat((String) this.sourceFile); + } else if (this.sourceFile instanceof URI) { + /** 源文件和生成的文件都保存在临时目录下 */ + String urlPath = ((URI) this.sourceFile).getPath(); + updateOutputFormat(urlPath); + } + + return FileWriteUtil.saveFile(this.sourceFile, outputFormat); + } +} +``` + +参数的设置相关的比较清晰,唯一需要注意的是输出`asFile()`,这个里面实现了一些有意思的东西 + +- 保存原图片(将网络/二进制的原图,保存到本地) +- 生成临时输出文件 +- 命令执行 + +上面前两个,主要是借助辅助工具 FileWriteUtil实现,与主题的关联不大,但是内部东西还是很有意思的,推荐查看: + +- [https://github.com/liuyueyi/quick-media/blob/master/plugins/base-plugin/src/main/java/com/github/hui/quick/plugin/base/FileWriteUtil.java](https://github.com/liuyueyi/quick-media/blob/master/plugins/base-plugin/src/main/java/com/github/hui/quick/plugin/base/FileWriteUtil.java) + + +命令执行的封装如下(就是解析Operate参数,翻译成对应的IMOperation) + +```java +/** + * 执行图片的复合操作 + * + * @param operates + * @param sourceFilename 原始图片名 + * @param outputFilename 生成图片名 + * @return + * @throws ImgOperateException + */ +public static boolean operate(List operates, String sourceFilename, String outputFilename) throws ImgOperateException { + try { + IMOperation op = new IMOperation(); + boolean operateTag = false; + String waterFilename = null; + for (ImgWrapper.Builder.Operate operate : operates) { + if (!operate.valid()) { + continue; + } + + if (operate.getOperateType() == ImgWrapper.Builder.OperateType.CROP) { + op.crop(operate.getWidth(), operate.getHeight(), operate.getX(), operate.getY()); +// if (operate.getRadio() != null && Math.abs(operate.getRadio() - 1.0) > 0.005) { +// // 需要对图片进行缩放 +// op.resize((int) Math.ceil(operate.getWidth() * operate.getRadio())); +// } + operateTag = true; + } else if (operate.getOperateType() == ImgWrapper.Builder.OperateType.ROTATE) { + // fixme 180度旋转后裁图,会出现bug, 先这么兼容 + double rotate = operate.getRotate(); + if (Math.abs((rotate % 360) - 180) <= 0.005) { + rotate += 0.01; + } + op.rotate(rotate); + operateTag = true; + } else if (operate.getOperateType() == ImgWrapper.Builder.OperateType.SCALE) { + if (operate.getRadio() == null) { + if (operate.isForceScale()) { // 强制根据给定的参数进行压缩时 + StringBuilder builder = new StringBuilder(); + builder.append("!").append(operate.getWidth() == null ? "" : operate.getWidth()).append("x"); + builder.append(operate.getHeight() == null ? "" : operate.getHeight()); + op.addRawArgs("-resize", builder.toString()); + } else { + op.resize(operate.getWidth(), operate.getHeight()); + } + } else if(Math.abs(operate.getRadio() - 1) > 0.005) { + // 对图片进行比例缩放 + op.addRawArgs("-resize", "%" + (operate.getRadio() * 100)); + } + + if (operate.getQuality() != null && operate.getQuality() > 0) { + op.quality(operate.getQuality().doubleValue()); + } + operateTag = true; + } else if (operate.getOperateType() == ImgWrapper.Builder.OperateType.FLIP) { + op.flip(); + operateTag = true; + } else if (operate.getOperateType() == ImgWrapper.Builder.OperateType.FLOP) { + op.flop(); + operateTag = true; + } else if (operate.getOperateType() == ImgWrapper.Builder.OperateType.WATER && waterFilename == null) { + // 当前只支持添加一次水印 + op.geometry(operate.getWidth(), operate.getHeight(), operate.getX(), operate.getY()) + .composite(); + waterFilename = operate.getWaterFilename(); + operateTag = true; + } else if (operate.getOperateType() == ImgWrapper.Builder.OperateType.BOARD) { + op.border(operate.getWidth(), operate.getHeight()).bordercolor(operate.getColor()); + operateTag = true; + } + } + + if (!operateTag) { + throw new ImgOperateException("operate illegal! operates: " + operates); + } + op.addImage(sourceFilename); + if (waterFilename != null) { + op.addImage(waterFilename); + } + op.addImage(outputFilename); + /** 传true到构造函数中,则表示使用GraphicMagic, 裁图时,图片大小会变 */ + ConvertCmd convert = new ConvertCmd(); + convert.run(op); + } catch (IOException e) { + log.error("file read error!, e: {}", e); + return false; + } catch (InterruptedException e) { + log.error("interrupt exception! e: {}", e); + return false; + } catch (IM4JavaException e) { + log.error("im4java exception! e: {}", e); + return false; + } + return true; +} +``` + +### 5. 接口封装 + +包装一个对外使用的方式 + +```java +public class ImgWrapper { + /** + * 根据本地图片进行处理 + * + * @param file + * @return + */ + public static Builder of(String file) { + checkForNull(file, "Cannot specify null for input file."); + if (file.startsWith("http")) { + throw new IllegalArgumentException("file should not be URI resources! file: " + file); + } + return Builder.ofString(file); + } + + public static Builder of(URI uri) { + checkForNull(uri, "Cannot specify null for input uri."); + return Builder.ofUrl(uri); + } + + public static Builder of(InputStream inputStream) { + checkForNull(inputStream, "Cannot specify null for InputStream."); + return Builder.ofStream(inputStream); + } + + + private static void checkForNull(Object o, String message) { + if (o == null) { + throw new NullPointerException(message); + } + } +} +``` + +## IV. 测试 + +上面基本上完成了整个接口的设计与实现,接下来就是接口测试了 + + +给出几个使用姿势演示,更多可以查看:[ImgWrapperTest](https://github.com/liuyueyi/quick-media/blob/master/plugins/imagic-plugin/src/test/java/com/github/hui/quick/plugin/test/ImgWrapperTest.java) + +```java +private static final String url = "http://a.hiphotos.baidu.com/image/pic/item/14ce36d3d539b6006a6cc5d0e550352ac65cb733.jpg"; +private static final String localFile = "blogInfoV2.png"; + +@Test +public void testCutImg() { + + try { + // 保存到本地 + ImgWrapper.of(URI.create(url)) + .crop(10, 20, 500, 500) + .toFile(); + } catch (Exception e) { + e.printStackTrace(); + } +} + + +@Test +public void testRotateImg() { + try { + InputStream stream = FileReadUtil.getStreamByFileName(localFile); + BufferedImage img = ImgWrapper.of(stream).rotate(90).asImg(); + System.out.println("----" + img); + } catch (Exception e) { + e.printStackTrace(); + } +} + + +@Test +public void testWater() { + BufferedImage img; + try { + img = ImgWrapper.of(URI.create(url)) + .board(10, 10, "red") + .water(localFile, 100, 100) + .asImg(); + System.out.println("--- " + img); + } catch (Exception e) { + e.printStackTrace(); + } +} +``` + +## V. 其他 + +### 项目: + +**GitHub:** + +- 项目:[Quick-Media](https://github.com/liuyueyi/quick-media) +- 源码:[imagic-plugin](https://github.com/liuyueyi/quick-media/tree/master/plugins/imagic-plugin) + +**Gitee:** + +- 项目:[Quick-Media](https://gitee.com/liuyueyi/quick-media) +- 源码:[imagic-plugin](https://gitee.com/liuyueyi/quick-media/tree/master/plugins/imagic-plugin) + + +### 个人博客: [一灰灰Blog](https://liuyueyi.github.io/hexblog) + +基于hexo + github pages搭建的个人博客,记录所有学习和工作中的博文,欢迎大家前去逛逛 + + +### 声明 + +尽信书则不如,已上内容,纯属一家之言,因本人能力一般,见识有限,如发现bug或者有更好的建议,随时欢迎批评指正 + +- 微博地址: [小灰灰Blog](https://weibo.com/p/1005052169825577/home) +- QQ: 一灰灰/3302797840 + +### 扫描关注 + +![QrCode](https://raw.githubusercontent.com/liuyueyi/Source/master/img/info/blogInfoV2.png) diff --git "a/docs/\346\217\222\344\273\266/magic/\346\246\202\350\247\210.md" "b/docs/\346\217\222\344\273\266/magic/\346\246\202\350\247\210.md" new file mode 100644 index 00000000..3bf12917 --- /dev/null +++ "b/docs/\346\217\222\344\273\266/magic/\346\246\202\350\247\210.md" @@ -0,0 +1 @@ +# 概览 \ No newline at end of file diff --git "a/docs/\346\217\222\344\273\266/markdown/markdown\350\275\254html.md" "b/docs/\346\217\222\344\273\266/markdown/markdown\350\275\254html.md" new file mode 100644 index 00000000..663b2e39 --- /dev/null +++ "b/docs/\346\217\222\344\273\266/markdown/markdown\350\275\254html.md" @@ -0,0 +1,339 @@ +# markdown to html +## 背景 +将markdown文档转换为html,主要是web应用中有些场景会用到,如博客系统,支持markdown语法的评论功能等 + +要自己去实现这个功能,并没有那么简单,当然面向GitHub编程,就简单很多了 + +## 设计 + +### 1. markdown 转 html + +在github上相关的开源包还是比较多的,选择了一个之前看 Solo (一个开源的java博客系统)源码时,接触到的辅助包 `flexmark` + +因为`flexmark` 工程比较庞大,我们这里只依赖其中的markdown转html的工具类,所以只需要添加下面的依赖即可 + +```java + + + com.vladsch.flexmark + flexmark + 0.26.4 + + + com.vladsch.flexmark + flexmark-util + 0.26.4 + + + + com.vladsch.flexmark + flexmark-ext-tables + 0.26.4 + +``` + + +使用姿势也比较简单,从demo中查看,下面给出一个从文件中读取内容并转换的过程 + +```java +// 从文件中读取markdown内容 +InputStream stream = this.getClass().getClassLoader().getResourceAsStream("test.md"); +BufferedReader reader = new BufferedReader(new InputStreamReader(stream, "utf-8")); + +List list = reader.lines().collect(Collectors.toList()); +String content = Joiner.on("\n").join(list); + + + +// markdown to image +MutableDataSet options = new MutableDataSet(); +options.setFrom(ParserEmulationProfile.MARKDOWN); +options.set(Parser.EXTENSIONS, Arrays.asList(new Extension[] { TablesExtension.create()})); +Parser parser = Parser.builder(options).build(); +HtmlRenderer renderer = HtmlRenderer.builder(options).build(); + +Node document = parser.parse(content); +String html = renderer.render(document); +``` + +## 实现 + +> 上面给出了设计思路,主要是利用开源包进行转换,在此基础上进行封装,使得调用方式更加友好 + + +### 0. 依赖 + +pom直接依赖即可 + +```java + + + com.vladsch.flexmark + flexmark + ${flexmark.version} + + + com.vladsch.flexmark + flexmark-util + ${flexmark.version} + + + com.vladsch.flexmark + flexmark-ext-tables + ${flexmark.version} + + +``` + +### 1. `MarkdownEntity` + +这个entity类除了markdown转换后的html内容之外,还增加了`css` 和 `divStyle` 属性 + +- `css` 属性,主要是用于美化输出html的展示样式 +- `divStyle` 同样也是为了定义一些通用的属性,会在html内容外层加一个`

`标签,可以在其中进行统一的宽高设置,字体.... + +```java +@Data +public class MarkdownEntity { + + public static String TAG_WIDTH = ""; + + // css 样式 + private String css; + + // 最外网的div标签, 可以用来设置样式,宽高,字体等 + private Map divStyle = new ConcurrentHashMap<>(); + + // 转换后的html文档 + private String html; + + public MarkdownEntity() { + } + + public MarkdownEntity(String html) { + this.html = html; + } + + @Override + public String toString() { + return css + "\n
\n" + html + "\n
"; + } + + + private String parseDiv() { + StringBuilder builder = new StringBuilder(); + for (Map.Entry entry : divStyle.entrySet()) { + builder.append(entry.getKey()).append("=\"") + .append(entry.getValue()).append("\" "); + } + return builder.toString(); + } + + + public void addDivStyle(String attrKey, String value) { + if (divStyle.containsKey(attrKey)) { + divStyle.put(attrKey, divStyle.get(attrKey) + " " + value); + } else { + divStyle.put(attrKey, value); + } + } + + + public void addWidthCss(String tag) { + String wcss = String.format(TAG_WIDTH, tag); + css += wcss; + } +} +``` + +### 2. `MarkDown2HtmlWrapper` +> 操作封装类 + +- 从git上找了一个简单`markdown.css`样式, 为了避免每次都去文件中读,这里定义一个静态变量 `MD_CSS` +- 为了利用css样式,需要给 `MarkdownEntity` 的 divStyle 新增一个 `class: markdown-body `样式 +- markdown to html 的主要逻辑在 `parse` 方法中,注意下为了支持table,加载了对应的table插件 + +```java +public class MarkDown2HtmlWrapper { + + private static String MD_CSS = null; + + static { + try { + MD_CSS = FileReadUtil.readAll("md/huimarkdown.css"); + MD_CSS = "\n"; + } catch (Exception e) { + MD_CSS = ""; + } + } + + + /** + * 将本地的markdown文件,转为html文档输出 + * + * @param path 相对地址or绝对地址 ("/" 开头) + * @return + * @throws IOException + */ + public static MarkdownEntity ofFile(String path) throws IOException { + return ofStream(FileReadUtil.getStreamByFileName(path)); + } + + + /** + * 将网络的markdown文件,转为html文档输出 + * + * @param url http开头的url格式 + * @return + * @throws IOException + */ + public static MarkdownEntity ofUrl(String url) throws IOException { + return ofStream(FileReadUtil.getStreamByFileName(url)); + } + + + /** + * 将流转为html文档输出 + * + * @param stream + * @return + */ + public static MarkdownEntity ofStream(InputStream stream) { + BufferedReader bufferedReader = new BufferedReader( + new InputStreamReader(stream, Charset.forName("UTF-8"))); + List lines = bufferedReader.lines().collect(Collectors.toList()); + String content = Joiner.on("\n").join(lines); + return ofContent(content); + } + + + /** + * 直接将markdown语义的文本转为html格式输出 + * + * @param content markdown语义文本 + * @return + */ + public static MarkdownEntity ofContent(String content) { + String html = parse(content); + MarkdownEntity entity = new MarkdownEntity(); + entity.setCss(MD_CSS); + entity.setHtml(html); + entity.addDivStyle("class", "markdown-body "); + return entity; + } + + + /** + * markdown to image + * + * @param content markdown contents + * @return parse html contents + */ + public static String parse(String content) { + MutableDataSet options = new MutableDataSet(); + options.setFrom(ParserEmulationProfile.MARKDOWN); + + // enable table parse! + options.set(Parser.EXTENSIONS, Arrays.asList(TablesExtension.create())); + + + Parser parser = Parser.builder(options).build(); + HtmlRenderer renderer = HtmlRenderer.builder(options).build(); + + Node document = parser.parse(content); + return renderer.render(document); + } + +} +``` + + +## 测试 + +测试代码比较简单,下面三行即可 + +```java +@Test +public void markdown2html() throws IOException { + String file = "md/tutorial.md"; + MarkdownEntity html = MarkDown2HtmlWrapper.ofFile(file); + System.out.println(html.toString()); +} +``` + + +markdown 文件如下 + +``` +Markdown cells support standard Markdown syntax as well as GitHub Flavored Markdown (GFM). Open the preview to see these rendered. + +### Basics + +# H1 +## H2 +### H3 +#### H4 +##### H5 +###### H6 + +--- + +*italic*, **bold**, ~~Scratch this.~~ + +`inline code` + +### Lists + +1. First ordered list item +2. Another item + * Unordered sub-list. +1. Actual numbers don't matter, just that it's a number + 1. Ordered sub-list +4. And another item. + +### Quote + +> Peace cannot be kept by force; it can only be achieved by understanding. + +### Links + +[I'm an inline-style link](https://www.google.com) +http://example.com + +You can also create a link to another note: (Note menu -> Copy Note Link -> Paste) +[01 - Getting Started](quiver-note-url/D2A1CC36-CC97-4701-A895-EFC98EF47026) + +### Tables + +| Tables | Are | Cool | +| ------------- |:-------------:| -----:| +| col 3 is | right-aligned | $1600 | +| col 2 is | centered | $12 | +| zebra stripes | are neat | $1 | + +### GFM Task Lists + +- [ ] a task list item +- [ ] list syntax required +- [ ] normal **formatting**, @mentions, #1234 refs +- [ ] incomplete +- [x] completed + +### Inline LaTeX + +You can use inline LaTeX inside Markdown cells as well, for example, $x^2$. +``` + +测试示意图 + +![testCase](https://static.oschina.net/uploads/img/201709/11200153_lCgP.gif) + +## 其他 + +项目地址:[https://github.com/liuyueyi/quick-media](https://github.com/liuyueyi/quick-media) + +个人博客:[一灰的个人博客](http://blog.zbang.online:8080) + +公众号获取更多: + +![个人信息](https://static.oschina.net/uploads/img/201709/05212311_hPmi.png "个人信息") \ No newline at end of file diff --git "a/docs/\346\217\222\344\273\266/markdown/markdown\350\275\254image.md" "b/docs/\346\217\222\344\273\266/markdown/markdown\350\275\254image.md" new file mode 100644 index 00000000..767814cf --- /dev/null +++ "b/docs/\346\217\222\344\273\266/markdown/markdown\350\275\254image.md" @@ -0,0 +1,392 @@ +# markdown 转 image + +> 前段时间实现了长图文生成的基本功能,然后想了下能否有个进阶版,直接将markdown生成渲染后的图片呢? + +## 思路 +> 有不少的库可以将 markdown 转为 html,那么这个需求就可以转为 html转Image了 + +### 1. markdown 转 html + +可以参看之前的博文[《Java 实现 markdown转Html》](https://my.oschina.net/u/566591/blog/1535380) + +### 2. html 转 图片 + +主要的核心问题就在这里了,如何实现html转图片? + +- 直接实现html转图片的包没怎么见,看到一个 `html2image`, 还不太好用 +- 在 AWT or Swing 的Panel上显示网页,在把Panel输出为 image 文件 +- 使用js相关技术实现转换 + +本篇博文具体实现以 `html2image` 的实现逻辑作为参考,然后定制实现一把(后面有机会写一篇利用js来实现html转图片的博文) + +#### html2image 的实现原理 + +`html2image` 基本上没啥维护了,内部主要是利用了 `xhtmlrender` 实现html渲染为图片 + +```java +Graphics2DRenderer renderer = new Graphics2DRenderer(); +// 设置渲染内容 +renderer.setDocument(document, document.getDocumentURI()); + +// 获取Graphics2D +graphics2D = bufferedImage.createGraphics(); +renderer.layout(graphics2D, dimension); + +// 内容渲染 +renderer.render(graphics2D); +``` + +#### 说明 + +1. 为什么并不直接使用 `java-html2image` ? + + - 因为有些定制的场景支持得不太友好,加上源码也比较简单,所以干脆站在前人的基础上进行拓展 + +2. 设计目标(这里指html转图片的功能) + + - 生成图片的宽可指定 + - 支持对线上网页进行转图片 + - 支持对html中指定的区域进行转换 + - css样式渲染支持 + + +## 实现 + +> 本篇先会先实现一个基本的功能,即读去`markdown`文档, 并转为一张图片 + + +### 1. markdown 转 html 封装 +> 利用之前封装的 `MarkDown2HtmlWrapper` 工具类 + +具体实现逻辑参考项目工程,和markdown转html博文 + +### 2. html 转 image + +#### 参数配置项 `HtmlRenderOptions` + +**注意** + +- html 为 Document 属性 +- autoW, autoH 用于控制是否自适应html实际的长宽 + +```java +@Data +public class HtmlRenderOptions { + + /** + * 输出图片的宽 + */ + private Integer w; + + + /** + * 输出图片的高 + */ + private Integer h; + + + /** + * 是否自适应宽 + */ + private boolean autoW; + + + /** + * 是否自适应高 + */ + private boolean autoH; + + + /** + * 输出图片的格式 + */ + private String outType; + + /** + * html相关内容 + */ + private Document document; +} +``` + +### 封装处理类 +> 同样采用Builder模式来进行配置项设置 + +```java +public class Html2ImageWrapper { + + private static DOMParser domParser; + + static { + domParser = new DOMParser(new HTMLConfiguration()); + try { + domParser.setProperty("http://cyberneko.org/html/properties/names/elems", "lower"); + } catch (Exception e) { + throw new RuntimeException("Can't create HtmlParserImpl", e); + } + } + + + private HtmlRenderOptions options; + + + private Html2ImageWrapper(HtmlRenderOptions options) { + this.options = options; + } + + + private static Document parseDocument(String content) throws Exception { + domParser.parse(new InputSource(new StringReader(content))); + return domParser.getDocument(); + } + + + public static Builder of(String html) { + return new Builder().setHtml(html); + } + + + public static Builder ofMd(MarkdownEntity entity) { + return new Builder().setHtml(entity); + } + + + public BufferedImage asImage() { + BufferedImage bf = HtmlRender.parseImage(options); + return bf; + } + + + public boolean asFile(String absFileName) throws IOException { + File file = new File(absFileName); + FileUtil.mkDir(file); + + BufferedImage bufferedImage = asImage(); + if (!ImageIO.write(bufferedImage, options.getOutType(), file)) { + throw new IOException("save image error!"); + } + + return true; + } + + + public String asString() throws IOException { + BufferedImage img = asImage(); + return Base64Util.encode(img, options.getOutType()); + } + + + @Getter + public static class Builder { + /** + * 输出图片的宽 + */ + private Integer w = 600; + + /** + * 输出图片的高度 + */ + private Integer h; + + /** + * true,根据网页的实际宽渲染; + * false, 则根据指定的宽进行渲染 + */ + private boolean autoW = true; + + /** + * true,根据网页的实际高渲染; + * false, 则根据指定的高进行渲染 + */ + private boolean autoH = false; + + + /** + * 输出图片的格式 + */ + private String outType = "jpg"; + + + /** + * 待转换的html内容 + */ + private MarkdownEntity html; + + + public Builder setW(Integer w) { + this.w = w; + return this; + } + + public Builder setH(Integer h) { + this.h = h; + return this; + } + + public Builder setAutoW(boolean autoW) { + this.autoW = autoW; + return this; + } + + public Builder setAutoH(boolean autoH) { + this.autoH = autoH; + return this; + } + + public Builder setOutType(String outType) { + this.outType = outType; + return this; + } + + + public Builder setHtml(String html) { + this.html = new MarkdownEntity(); + return this; + } + + + public Builder setHtml(MarkdownEntity html) { + this.html = html; + return this; + } + + public Html2ImageWrapper build() throws Exception { + HtmlRenderOptions options = new HtmlRenderOptions(); + options.setW(w); + options.setH(h); + options.setAutoW(autoW); + options.setAutoH(autoH); + options.setOutType(outType); + + + if (fontColor != null) { + html.addDivStyle("style", "color:" + options.getFontColor()); + } + html.addDivStyle("style", "width:" + w + ";"); + html.addWidthCss("img"); + html.addWidthCss("code"); + + options.setDocument(parseDocument(html.toString())); + + return new Html2ImageWrapper(options); + } + + } +} +``` + +上面的实现,有个需要注意的地方 + +**如何将html格式的字符串,转为 Document 对象** + +利用了开源工具 `nekohtml`, 可以较好的实现html标签解析,看一下`DOMParse` 的初始化过程 + + +```java +private static DOMParser domParser; + +static { + domParser = new DOMParser(new HTMLConfiguration()); + try { + domParser.setProperty("http://cyberneko.org/html/properties/names/elems", + "lower"); + } catch (Exception e) { + throw new RuntimeException("Can't create HtmlParserImpl", e); + } +} +``` + +try语句块中的内容并不能缺少,否则最终的样式会错乱,关于 `nekohtml` 的使用说明,可以查阅相关教程 + + +上面的封装,主要是`HtmlRenderOptions`的构建,主要的渲染逻辑则在下面 + +### 渲染 + +利用 `xhtmlrenderer` 实现html的渲染 + +- 宽高的自适应 +- 图片的布局,内容渲染 + +```java +public class HtmlRender { + + /** + * 输出图片 + * + * @param options + * @return + */ + public static BufferedImage parseImage(HtmlRenderOptions options) { + int width = options.getW(); + int height = options.getH() == null ? 1024 : options.getH(); + Graphics2DRenderer renderer = new Graphics2DRenderer(); + renderer.setDocument(options.getDocument(), options.getDocument().getDocumentURI()); + + + Dimension dimension = new Dimension(width, height); + BufferedImage bufferedImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics2D = GraphicUtil.getG2d(bufferedImage); + + // 自适应修改生成图片的宽高 + if (options.isAutoH() || options.getH() == null) { + // do layout with temp buffer + renderer.layout(graphics2D, new Dimension(width, height)); + graphics2D.dispose(); + + Rectangle size = renderer.getMinimumSize(); + final int autoWidth = options.isAutoW() ? (int) size.getWidth() : width; + final int autoHeight = (int) size.getHeight(); + bufferedImage = new BufferedImage(autoWidth, autoHeight, BufferedImage.TYPE_INT_RGB); + dimension = new Dimension(autoWidth, autoHeight); + + graphics2D = GraphicUtil.getG2d(bufferedImage); + } + + + renderer.layout(graphics2D, dimension); + renderer.render(graphics2D); + graphics2D.dispose(); + return bufferedImage; + } +} +``` + +## 测试 + +```java +@Test +public void testParse() throws Exception { + String file = "md/tutorial.md"; + + MarkdownEntity html = MarkDown2HtmlWrapper.ofFile(file); + + BufferedImage img = Html2ImageWrapper.ofMd(html) + .setW(600) + .setAutoW(false) + .setAutoH(true) + .setOutType("jpg") + .build() + .asImage(); + + ImageIO.write(img, "jpg", new File("/Users/yihui/Desktop/md.jpg")); +} +``` + +输出图片 + +![out.jpg](https://camo.githubusercontent.com/90dc0e400056650e581439945f82a193d4ae9f17/687474703a2f2f75706c6f61642d696d616765732e6a69616e7368752e696f2f75706c6f61645f696d616765732f313430353933362d336164353064323937386533643465302e6a70673f696d6167654d6f6772322f6175746f2d6f7269656e742f7374726970253743696d61676556696577322f322f772f31323430) + +然后演示一个对项目中实际的教程文档输出图片的动态示意图, 因为生成的图片特别特别长,所以就不贴输出的图片了,有兴趣的同学可以下载工程,实际跑一下看看 + +源markdown文件地址: + +[https://github.com/liuyueyi/quick-media/blob/master/doc/images/imgGenV2.md](https://github.com/liuyueyi/quick-media/blob/master/doc/images/imgGenV2.md) + + +![show.gif](https://camo.githubusercontent.com/113c4e98f0dc2d149d74d23fca154368bfb7f7aa/687474703a2f2f75706c6f61642d696d616765732e6a69616e7368752e696f2f75706c6f61645f696d616765732f313430353933362d613734393739653762303736623335322e6769663f696d6167654d6f6772322f6175746f2d6f7269656e742f7374726970) + + +## 参考博文 + +- [Java 实现HTML 页面转成image 图片](https://www.2cto.com/kf/201303/196946.html) \ No newline at end of file diff --git "a/docs/\346\217\222\344\273\266/markdown/\346\246\202\350\247\210.md" "b/docs/\346\217\222\344\273\266/markdown/\346\246\202\350\247\210.md" new file mode 100644 index 00000000..3bf12917 --- /dev/null +++ "b/docs/\346\217\222\344\273\266/markdown/\346\246\202\350\247\210.md" @@ -0,0 +1 @@ +# 概览 \ No newline at end of file diff --git "a/docs/\346\217\222\344\273\266/phantom/Java&PhantomJs\345\256\236\347\216\260html\350\276\223\345\207\272\345\233\276\347\211\207.md" "b/docs/\346\217\222\344\273\266/phantom/Java&PhantomJs\345\256\236\347\216\260html\350\276\223\345\207\272\345\233\276\347\211\207.md" new file mode 100644 index 00000000..5504ec48 --- /dev/null +++ "b/docs/\346\217\222\344\273\266/phantom/Java&PhantomJs\345\256\236\347\216\260html\350\276\223\345\207\272\345\233\276\347\211\207.md" @@ -0,0 +1,125 @@ +## Java & PhantomJs 实现html输出图片 +> 借助phantomJs来实现将html网页输出为图片 + + +## 前提准备 + +### 1. phantom.js 安装 + +```sh +# 1. 下载 + +## mac 系统 +wget https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-2.1.1-macosx.zip + + +## linux 系统 +wget https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-2.1.1-linux-x86_64.tar.bz2 + +## windows 系统 +## 就不要玩了,没啥意思 + + +# 2. 解压 + +sudo su +tar -jxvf phantomjs-2.1.1-linux-x86_64.tar.bz2 + +# 如果解压报错,则安装下面的 +# yum -y install bzip2 + +# 3. 安装 + +## 简单点,移动到bin目录下 + +cp phantomjs-2.1.1-linux-x86_64/bin/phantomjs /usr/local/bin + +# 4. 验证是否ok +phantomjs --version + +# 输出版本号,则表示ok +``` + +### 2. java依赖配置 + +maven 配置添加依赖 + +```xml + + + org.seleniumhq.selenium + selenium-java + 2.53.1 + + + com.github.detro + ghostdriver + 2.1.0 + + + + + + + jitpack.io + https://jitpack.io + + +``` + + +## 开动 + +主要调用phantomjs来实现html渲染图片的逻辑如下 + +```java +public class Html2ImageByJsWrapper { + + private static PhantomJSDriver webDriver = getPhantomJs(); + + private static PhantomJSDriver getPhantomJs() { + //设置必要参数 + DesiredCapabilities dcaps = new DesiredCapabilities(); + //ssl证书支持 + dcaps.setCapability("acceptSslCerts", true); + //截屏支持 + dcaps.setCapability("takesScreenshot", true); + //css搜索支持 + dcaps.setCapability("cssSelectorsEnabled", true); + //js支持 + dcaps.setJavascriptEnabled(true); + //驱动支持(第二参数表明的是你的phantomjs引擎所在的路径,which/whereis phantomjs可以查看) + // fixme 这里写了执行, 可以考虑判断系统是否有安装,并获取对应的路径 or 开放出来指定路径 + dcaps.setCapability(PhantomJSDriverService.PHANTOMJS_EXECUTABLE_PATH_PROPERTY, "/usr/local/bin/phantomjs"); + //创建无界面浏览器对象 + return new PhantomJSDriver(dcaps); + } + + + public static BufferedImage renderHtml2Image(String url) throws IOException { + webDriver.get(url); + File file = webDriver.getScreenshotAs(OutputType.FILE); + return ImageIO.read(file); + } + +} +``` + +## 测试case + +```java +@Test +public void testRender() throws IOException { + BufferedImage img = null; + for (int i = 0; i < 20; ++i) { + String url = "https://my.oschina.net/u/566591/blog/1580020"; + long start = System.currentTimeMillis(); + img = Html2ImageByJsWrapper.renderHtml2Image(url); + long end = System.currentTimeMillis(); + System.out.println("cost: " + (end - start)); + } + + System.out.println(DomUtil.toDomSrc(Base64Util.encode(img, "png"), MediaType.ImagePng)); + +} +``` diff --git "a/docs/\346\217\222\344\273\266/phantom/\346\246\202\350\247\210.md" "b/docs/\346\217\222\344\273\266/phantom/\346\246\202\350\247\210.md" new file mode 100644 index 00000000..3bf12917 --- /dev/null +++ "b/docs/\346\217\222\344\273\266/phantom/\346\246\202\350\247\210.md" @@ -0,0 +1 @@ +# 概览 \ No newline at end of file diff --git "a/docs/\346\217\222\344\273\266/svg/\346\246\202\350\247\210.md" "b/docs/\346\217\222\344\273\266/svg/\346\246\202\350\247\210.md" new file mode 100644 index 00000000..2150da5b --- /dev/null +++ "b/docs/\346\217\222\344\273\266/svg/\346\246\202\350\247\210.md" @@ -0,0 +1,2 @@ +# 概览 + diff --git "a/docs/\346\217\222\344\273\266/\344\272\214\347\273\264\347\240\201/\344\272\214\347\273\264\347\240\201\346\217\222\344\273\266\346\246\202\350\247\210.md" "b/docs/\346\217\222\344\273\266/\344\272\214\347\273\264\347\240\201/\344\272\214\347\273\264\347\240\201\346\217\222\344\273\266\346\246\202\350\247\210.md" new file mode 100644 index 00000000..ac21d6b3 --- /dev/null +++ "b/docs/\346\217\222\344\273\266/\344\272\214\347\273\264\347\240\201/\344\272\214\347\273\264\347\240\201\346\217\222\344\273\266\346\246\202\350\247\210.md" @@ -0,0 +1,6 @@ +# 二维码插件概览 +> qrcode-plugin 在开源二维码处理库zxing的基础上,重写了二维码渲染的逻辑,以支持更灵活、更个性化的二维码生成规则 + +## 1. 使用尝鲜 + +## 2. 功能介绍 \ No newline at end of file diff --git "a/docs/\346\217\222\344\273\266/\344\272\214\347\273\264\347\240\201/\344\272\214\347\273\264\347\240\201\347\224\237\346\210\220\346\234\215\345\212\241\344\271\213\346\267\261\345\272\246\345\256\232\345\210\266.md" "b/docs/\346\217\222\344\273\266/\344\272\214\347\273\264\347\240\201/\344\272\214\347\273\264\347\240\201\347\224\237\346\210\220\346\234\215\345\212\241\344\271\213\346\267\261\345\272\246\345\256\232\345\210\266.md" new file mode 100644 index 00000000..f11c274d --- /dev/null +++ "b/docs/\346\217\222\344\273\266/\344\272\214\347\273\264\347\240\201/\344\272\214\347\273\264\347\240\201\347\224\237\346\210\220\346\234\215\345\212\241\344\271\213\346\267\261\345\272\246\345\256\232\345\210\266.md" @@ -0,0 +1,760 @@ +# 二维码生成服务之深度定制 +> 之前写了一篇二维码服务定制的博文,现在则在之前的基础上,再进一步,花样的实现深度定制的需求,我们的目标是二维码上的一切都是可以由用户来随意指定 + +## 设计 + +### 1. 技术相关 + +- zxing 开源包用于生成二维码 +- springboot 搭建基本web服务,提供http接口 +- awt 用于图片的编辑 +- httpclient 用于从网络下载图片 +- lombok 简化编码 + + +### 2. 目的 +> 既然是对二维码服务的深度定制,那我们的目的基本上就是二维码上面出现的东西,都可以按照我们的需求进行改造 + +这里,我们设计两个目的,一个基础版,一个进阶版 + +- 基础版 + - 二维码大小 + - 边距留白指定 + - 添加logo + - 加背景 + +- 进阶版 + - 二维码中前置色和背景色可自由指定颜色 + - 二维码中前置色(黑白二维码中的黑色区域)可换成圆点,三角形等其他图形 + - 前置色可用图片替换 + - 探测点(三个矩形框就是探测点,也叫做定位点)颜色可配置 + - 探测点可用图片替换 + - 二维码样式(圆角矩形,添加边框,边框颜色可指定) + - 背景支持填充(填充在背景图片的某个区域)和覆盖方式(全覆盖背景图,二维码设置透明度) + +上面是我们希望达到的目的,下面给几个实际生成的二维码瞅瞅最终的效果 + +![生成demom](https://static.oschina.net/uploads/img/201708/12175500_AYTi.png "demo") + +(小灰灰blog公众号,实际测试时,请用微信扫一扫) + +### 3. 前提准备 + +#### 1.相关博文 + +在直接进入上面花样的二维码生成之前,有必要安利一把zxing的基本使用方式,本篇将不会对如何使用zxing进行说明,有需求了解的可以参考下面几篇相关博文,此篇博文是 `《spring-boot & zxing 搭建二维码服务》` 的衍生 + +- [java 实现二维码生成工具类](https://my.oschina.net/u/566591/blog/872728) +- [zxing 二维码大白边一步一步修复指南](https://my.oschina.net/u/566591/blog/872770) +- [spring-boot & zxing 搭建二维码服务](https://my.oschina.net/u/566591/blog/1457164) +- [二维码服务拓展(支持logo,圆角logo,背景图,颜色配置)](https://my.oschina.net/u/566591/blog/1491697) + +#### 2. 源码介绍 + +此外下面直接贴代码,可能有些地方不太容易理解,下面将简单对一些辅助类进行必要的功能说明 + +源码直通车:[quick-media](https://github.com/liuyueyi/quick-media) + +涉及到的工具类: + +- `QrCodeUtil` : 二维码生成工具类 + - 生成二维码矩阵 + - 根据二维码矩阵渲染二维码图片 +- `ImageUtil` : 图片处理工具类 + - 加载图片(支持从本地,网络获取图片) + - 绘制二维码logo + - 图片圆角化 + - 图片添加纯色边框 + - 背景绘制 + - 二维码绘制 +- `QrCodeOptions`: 二维码配置类 +- `BitMatrixEx`: 二维码矩阵信息扩展类 +- `QrCodeGenWrapper`: 二维码生成服务包装类,与用户进行交互的主要接口,设置配置信息,生成二维码,选择输出方式,都是通过它来设定 + +### 4. 实现说明 + +#### 第一步,生成矩阵 +我们直接利用zxing来生成二维码矩阵信息,并用来实例我们的矩阵拓展类 `BitMatrixEx` + +在我们的工程中,相关的代码为 + +```java +com.hust.hui.quickmedia.common.util.QrCodeUtil#encode +``` + +在这里,只关心下面几个参数的生成,其他的基本上就是zxing库的调用了 + +```java +/** + * 实际生成二维码的宽 + */ +private int width; + + +/** + * 实际生成二维码的高 + */ +private int height; + + +/** + * 左白边大小 + */ +private int leftPadding; + +/** + * 上白边大小 + */ +private int topPadding; + +/** + * 矩阵信息缩放比例 + */ +private int multiple; + +private ByteMatrix byteMatrix; +``` + +在理解为什么有上面的几个参数之前,有必要看一下`byteMatrix`到底是个什么东西?(自问自答:二维码矩阵) + +下面截出前面二维码中对应的矩阵信息,在生成一张二维码时,下面的1表示一个小黑块,0表示一个小白块; + +``` + 1 1 1 1 1 1 1 0 0 0 0 1 0 0 0 1 1 1 0 0 0 0 0 1 0 1 1 0 0 0 1 1 1 1 1 1 1 + 1 0 0 0 0 0 1 0 1 0 1 1 1 1 0 1 1 0 0 1 0 0 1 0 1 0 0 0 1 0 1 0 0 0 0 0 1 + 1 0 1 1 1 0 1 0 1 1 0 0 0 0 0 1 1 0 1 0 1 0 1 0 0 1 0 0 1 0 1 0 1 1 1 0 1 + 1 0 1 1 1 0 1 0 1 1 0 1 0 1 0 1 0 1 0 0 0 1 0 1 0 0 1 0 1 0 1 0 1 1 1 0 1 + 1 0 1 1 1 0 1 0 1 0 0 1 0 1 1 1 0 0 0 0 1 1 0 0 1 1 0 0 0 0 1 0 1 1 1 0 1 + 1 0 0 0 0 0 1 0 1 0 0 1 0 1 0 0 0 1 1 0 1 1 1 0 0 1 0 0 1 0 1 0 0 0 0 0 1 + 1 1 1 1 1 1 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 1 1 1 1 1 1 + 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 1 0 0 1 0 0 1 0 1 1 1 0 0 0 0 0 0 0 0 + 0 0 1 0 0 1 1 1 1 1 0 1 1 0 0 1 1 1 1 0 1 1 0 0 1 0 0 0 0 1 0 1 1 1 1 1 0 + 0 1 0 0 0 1 0 1 0 1 1 1 0 1 1 0 1 1 0 0 1 0 1 0 1 0 0 1 0 0 1 1 0 1 0 0 1 + 1 1 1 1 0 0 1 0 1 0 1 1 1 0 0 1 1 1 1 0 1 1 1 0 1 0 0 1 0 1 1 0 1 0 0 1 1 + 1 0 1 1 0 0 0 0 0 1 0 0 0 1 0 1 0 0 1 1 1 1 1 1 1 0 1 0 0 0 0 1 0 0 0 0 1 + 1 0 1 1 0 0 1 0 1 0 1 1 1 0 0 0 0 0 1 1 1 1 1 1 1 0 0 0 1 1 1 0 0 0 0 1 1 + 1 0 1 0 0 0 0 0 0 1 1 0 0 1 1 1 1 0 0 1 0 1 1 1 0 1 0 0 1 1 1 0 0 0 1 0 1 + 1 1 1 1 0 0 1 0 0 0 0 0 1 1 1 0 1 1 1 0 0 0 1 1 1 0 0 1 0 1 1 0 0 1 1 0 1 + 1 1 1 0 0 0 0 1 0 1 0 1 1 1 1 1 1 0 0 0 1 0 1 0 1 0 1 0 0 1 0 0 0 1 0 0 0 + 0 1 1 0 0 0 1 0 0 1 0 1 0 0 1 0 0 0 0 0 0 0 1 0 1 0 0 1 1 0 1 0 0 0 0 1 0 + 0 0 1 1 1 0 0 0 0 1 0 1 1 1 0 0 1 0 1 1 1 0 0 0 1 0 0 1 1 1 1 1 0 0 1 0 1 + 0 0 1 1 1 1 1 1 0 0 1 0 1 0 0 1 0 0 0 1 1 1 1 1 0 1 1 1 1 1 0 1 0 0 1 0 1 + 0 0 1 0 1 1 0 1 1 0 1 0 0 0 0 0 1 0 0 0 1 1 1 0 1 0 0 1 1 1 0 1 1 1 0 1 1 + 0 0 1 0 1 0 1 0 0 1 0 1 1 0 0 1 1 0 0 0 1 1 1 0 0 1 0 0 1 0 1 0 0 1 0 1 0 + 1 1 0 1 0 0 0 0 1 0 0 0 0 1 1 0 0 1 1 1 0 0 0 1 1 1 0 1 0 1 1 1 0 1 1 1 1 + 1 0 0 0 1 0 1 1 0 1 1 0 1 1 0 0 0 0 1 1 0 0 1 1 1 0 0 1 0 1 1 1 1 0 0 1 1 + 0 1 1 0 1 1 0 0 1 1 0 1 1 0 1 0 1 0 1 1 1 1 0 0 0 0 1 1 1 1 1 1 0 1 0 1 0 + 1 0 0 1 0 1 1 0 1 1 0 0 0 1 1 0 1 0 0 0 0 0 1 0 1 1 0 0 0 0 1 0 1 1 1 1 1 + 0 0 1 0 1 1 0 1 0 0 1 0 1 1 1 0 0 1 1 0 0 0 0 0 1 0 0 1 0 0 0 1 0 0 1 1 1 + 1 1 0 1 1 0 1 1 0 1 0 0 1 0 0 1 1 0 0 0 0 1 1 0 1 1 0 1 1 1 0 1 0 1 1 0 1 + 0 0 1 0 1 1 0 0 1 1 1 1 1 1 1 1 1 1 0 0 0 1 0 1 1 0 1 0 1 0 1 0 0 1 0 1 1 + 1 1 0 1 0 0 1 0 0 0 0 0 1 0 0 1 0 0 1 0 1 1 0 0 0 0 0 0 1 1 1 1 1 0 0 0 0 + 0 0 0 0 0 0 0 0 1 1 1 0 1 0 1 1 1 0 1 1 1 0 1 1 0 1 0 0 1 0 0 0 1 1 0 1 1 + 1 1 1 1 1 1 1 0 1 0 0 1 0 0 0 0 0 0 1 0 0 1 0 1 0 1 0 0 1 0 1 0 1 1 1 0 1 + 1 0 0 0 0 0 1 0 1 0 0 0 1 0 0 1 0 1 0 0 0 0 0 1 1 0 1 1 1 0 0 0 1 1 0 1 0 + 1 0 1 1 1 0 1 0 0 0 0 1 0 0 1 1 1 0 1 0 0 1 0 1 1 0 1 1 1 1 1 1 1 0 0 1 0 + 1 0 1 1 1 0 1 0 0 1 0 0 1 1 0 1 0 0 0 1 1 1 0 0 0 0 0 1 1 0 0 1 0 1 1 0 1 + 1 0 1 1 1 0 1 0 1 1 0 0 1 1 1 1 0 1 0 0 0 1 1 1 1 1 0 0 1 1 0 0 1 1 0 1 1 + 1 0 0 0 0 0 1 0 0 0 1 1 1 0 1 1 1 1 1 0 0 1 1 0 0 0 1 0 1 0 0 0 1 0 0 0 0 + 1 1 1 1 1 1 1 0 0 0 1 1 1 0 1 0 0 0 0 0 0 1 1 0 1 0 0 1 1 0 1 0 1 1 0 0 1 +``` + +当生成了上面的人矩阵之后,最终的二维码绘制都是根据上面的矩阵来的,将1的地方用我们希望绘制的样式(如圆点,三角形,图形等)来替换; + +上面的矩阵表示的基本的二维码信息,最终渲染二维码图片时,我们还需要知道最终的图片大小,四周的留白空间,每个二维码信息在放射到最终二维码图片时放大的倍数,有这些参数之后才能唯一指定最终的输出结果,所以就有了上面的几个参数 + + +#### 第二步, 二维码信息的绘制 + +根据上面的二维码矩阵来渲染二维码图片,先考虑最简单的,没有任何配置时,可以怎么玩? + +下面用到的参数来自`BitMatirxEx` + +1. 绘制整个背景(直接根据给定的宽高绘制矩形背景即可) + + ```java + g2.setColor(Color.WHITE); + g2.fillRect(0, 0, qrCodeWidth, qrCodeHeight); + ``` +2. 二维码矩阵中(x,y) == 1的地方绘制小方块 + + ```java + g2.setColor(Color.BLACK); + g2.fillRect(x+leftPadding, y+topPadding, multiple, multiple); + ``` +3. 根据2可知,整个渲染就是矩阵(二维数组)的遍历而已 + +---- + +根据上面的生成逻辑,我们可以很清晰的发现,有几个目标是可以很简单实现的 + +- 二维码背景色&前置色的指定(就是在1,2步骤中的setColor用指定的颜色替换即可) +- 替换二维码黑色小方块为其他图形 + +这里是一个小关键点了,在具体的实现中,我提供了: + - 三角形, + - 矩形(即二维码默认格式), + - 五边形(钻石), + - 六边形, + - 八边形, + - 圆形, + - 图片 + +**比较遗憾的是五角星没有支持,没想到合适的绘制方式** + +不同的样式,对应的绘制不同,我们定义了一个枚举,来定义不同的样式对应的绘制规则,优势就是扩展自定义样式方便,下面给出具体的绘制代码 + +```java +/** + * 绘制二维码信息的样式 + */ +public enum DrawStyle { + RECT { // 矩形 + + @Override + public void draw(Graphics2D g2d, int x, int y, int w, int h, BufferedImage img) { + g2d.fillRect(x, y, w, h); + } + + @Override + public boolean expand(ExpandType expandType) { + return true; + } + }, + CIRCLE { + // 圆点 + @Override + public void draw(Graphics2D g2d, int x, int y, int w, int h, BufferedImage img) { + g2d.fill(new Ellipse2D.Float(x, y, w, h)); + } + + @Override + public boolean expand(ExpandType expandType) { + return expandType == ExpandType.SIZE4; + } + }, + TRIANGLE { + // 三角形 + @Override + public void draw(Graphics2D g2d, int x, int y, int w, int h, BufferedImage img) { + int px[] = {x, x + (w >> 1), x + w}; + int py[] = {y + w, y, y + w}; + g2d.fillPolygon(px, py, 3); + } + + @Override + public boolean expand(ExpandType expandType) { + return false; + } + }, + DIAMOND { + // 五边形-钻石 + @Override + public void draw(Graphics2D g2d, int x, int y, int size, int h, BufferedImage img) { + int cell4 = size >> 2; + int cell2 = size >> 1; + int px[] = {x + cell4, x + size - cell4, x + size, x + cell2, x}; + int py[] = {y, y, y + cell2, y + size, y + cell2}; + g2d.fillPolygon(px, py, 5); + } + + @Override + public boolean expand(ExpandType expandType) { + return expandType == ExpandType.SIZE4; + } + }, + SEXANGLE { + // 六边形 + @Override + public void draw(Graphics2D g2d, int x, int y, int size, int h, BufferedImage img) { + int add = size >> 2; + int px[] = {x + add, x + size - add, x + size, x + size - add, x + add, x}; + int py[] = {y, y, y + add + add, y + size, y + size, y + add + add}; + g2d.fillPolygon(px, py, 6); + } + + @Override + public boolean expand(ExpandType expandType) { + return expandType == ExpandType.SIZE4; + } + }, + OCTAGON { + // 八边形 + @Override + public void draw(Graphics2D g2d, int x, int y, int size, int h, BufferedImage img) { + int add = size / 3; + int px[] = {x + add, x + size - add, x + size, x + size, x + size - add, x + add, x, x}; + int py[] = {y, y, y + add, y + size - add, y + size, y + size, y + size - add, y + add}; + g2d.fillPolygon(px, py, 8); + } + + @Override + public boolean expand(ExpandType expandType) { + return expandType == ExpandType.SIZE4; + } + }, + IMAGE { + // 自定义图片 + @Override + public void draw(Graphics2D g2d, int x, int y, int w, int h, BufferedImage img) { + g2d.drawImage(img, x, y, w, h, null); + } + + @Override + public boolean expand(ExpandType expandType) { + return true; + } + },; + + private static Map map; + + static { + map = new HashMap<>(7); + for (DrawStyle style : DrawStyle.values()) { + map.put(style.name(), style); + } + } + + public static DrawStyle getDrawStyle(String name) { + if (StringUtils.isBlank(name)) { // 默认返回矩形 + return RECT; + } + + + DrawStyle style = map.get(name.toUpperCase()); + return style == null ? RECT : style; + } + + + public abstract void draw(Graphics2D g2d, int x, int y, int w, int h, BufferedImage img); + + + /** + * 返回是否支持绘制图形的扩展 + * + * @param expandType + * @return + */ + public abstract boolean expand(ExpandType expandType); +} +``` + +--- + + +上面完成了二维码样式的定制,还有一个探测点(或者叫做定位点)的定制,也得在这一步中进行; + +普通的二维码结构如下 + +![二维码结构](https://static.oschina.net/uploads/img/201708/12175554_0vL7.png "二维码结构") + +探测点就是二维码中的三个方块,再看上面的二维码矩阵,下图中的两个红框内的其实就是上面的两个探测图形,外面的那层全0是分割符 + +![探测图形](https://static.oschina.net/uploads/img/201708/12175609_SD3H.png "在这里输入图片标题") + +两者一结合,很容易就可以搞定探测图形的位置,第一行有多少个连续的1就表示探测图形的size是多大 + +所以探测图形的私人定制就比较简单了,下面是具体的绘制代码(下面实现图片绘制,内外框采用不同颜色的实现) + +```java +// 设置三个位置探测图形 +if (x < detectCornerSize && y < detectCornerSize // 左上角 + || (x < detectCornerSize && y >= byteH - detectCornerSize) // 左下脚 + || (x >= byteW - detectCornerSize && y < detectCornerSize)) { // 右上角 + + if (qrCodeConfig.getDetectOptions().getDetectImg() != null) { + // 绘制图片 + g2.drawImage(qrCodeConfig.getDetectOptions().getDetectImg(), + leftPadding + x * infoSize, topPadding + y * infoSize, + infoSize * detectCornerSize, infoSize * detectCornerSize, null); + + for (int addX = 0; addX < detectCornerSize; addX++) { + for (int addY = 0; addY < detectCornerSize; addY++) { + bitMatrix.getByteMatrix().set(x + addX, y + addY, 0); + } + } + continue; + } + + + if (x == 0 || x == detectCornerSize - 1 || x == byteW - 1 || x == byteW - detectCornerSize + || y == 0 || y == detectCornerSize - 1 || y == byteH - 1 || y == byteH - detectCornerSize) { + // 外层的框 + g2.setColor(detectOutColor); + } else { + // 内层的框 + g2.setColor(detectInnerColor); + } + + g2.fillRect(leftPadding + x * infoSize, topPadding + y * infoSize, infoSize, infoSize); +} +``` + +--- + +到此,二维码主体的定制基本上over了,就最终的实现来看,我们的目标中除了logo和背景外,其他的基本上都是ok的,这里稍稍拓展了一点,如果连续两个为1,或一个小矩形全是1,则将这相同的几个串在一起,因此才有了上面的部分图形较大的情况(当然这个是可选的配置) + + +下面贴出整个绘制代码 + +```java +public static BufferedImage drawQrInfo(QrCodeOptions qrCodeConfig, BitMatrixEx bitMatrix) { + int qrCodeWidth = bitMatrix.getWidth(); + int qrCodeHeight = bitMatrix.getHeight(); + int infoSize = bitMatrix.getMultiple(); + BufferedImage qrCode = new BufferedImage(qrCodeWidth, qrCodeHeight, BufferedImage.TYPE_INT_RGB); + + + // 绘制的背景色 + Color bgColor = qrCodeConfig.getDrawOptions().getBgColor(); + // 绘制前置色 + Color preColor = qrCodeConfig.getDrawOptions().getPreColor(); + + // 探测图形外圈的颜色 + Color detectOutColor = qrCodeConfig.getDetectOptions().getOutColor(); + // 探测图形内圈的颜色 + Color detectInnerColor = qrCodeConfig.getDetectOptions().getInColor(); + + + int leftPadding = bitMatrix.getLeftPadding(); + int topPadding = bitMatrix.getTopPadding(); + + Graphics2D g2 = qrCode.createGraphics(); + g2.setComposite(AlphaComposite.Src); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + + // 直接背景铺满整个图 + g2.setColor(bgColor); + g2.fillRect(0, 0, qrCodeWidth, qrCodeHeight); + + // 探测图形的大小 + int detectCornerSize = bitMatrix.getByteMatrix().get(0, 5) == 1 ? 7 : 5; + + int byteW = bitMatrix.getByteMatrix().getWidth(); + int byteH = bitMatrix.getByteMatrix().getHeight(); + + boolean row2 = false; + boolean col2 = false; + QrCodeOptions.DrawStyle drawStyle = qrCodeConfig.getDrawOptions().getDrawStyle(); + for (int x = 0; x < byteW; x++) { + for (int y = 0; y < byteH; y++) { + if (bitMatrix.getByteMatrix().get(x, y) == 0) { + continue; + } + + // 设置三个位置探测图形 + if (x < detectCornerSize && y < detectCornerSize // 左上角 + || (x < detectCornerSize && y >= byteH - detectCornerSize) // 左下脚 + || (x >= byteW - detectCornerSize && y < detectCornerSize)) { // 右上角 + + if (qrCodeConfig.getDetectOptions().getDetectImg() != null) { + g2.drawImage(qrCodeConfig.getDetectOptions().getDetectImg(), + leftPadding + x * infoSize, topPadding + y * infoSize, + infoSize * detectCornerSize, infoSize * detectCornerSize, null); + + for (int addX = 0; addX < detectCornerSize; addX++) { + for (int addY = 0; addY < detectCornerSize; addY++) { + bitMatrix.getByteMatrix().set(x + addX, y + addY, 0); + } + } + continue; + } + + + if (x == 0 || x == detectCornerSize - 1 || x == byteW - 1 || x == byteW - detectCornerSize + || y == 0 || y == detectCornerSize - 1 || y == byteH - 1 || y == byteH - detectCornerSize) { + // 外层的框 + g2.setColor(detectOutColor); + } else { + // 内层的框 + g2.setColor(detectInnerColor); + } + + g2.fillRect(leftPadding + x * infoSize, topPadding + y * infoSize, infoSize, infoSize); + } else { // 着色二维码主题 + g2.setColor(preColor); + + if (!qrCodeConfig.getDrawOptions().isEnableScale()) { + drawStyle.draw(g2, + leftPadding + x * infoSize, + topPadding + y * infoSize, + infoSize, + infoSize, + qrCodeConfig.getDrawOptions().getImg()); + continue; + } + + + // 支持拓展时 + row2 = rightTrue(bitMatrix.getByteMatrix(), x, y); + col2 = belowTrue(bitMatrix.getByteMatrix(), x, y); + + if (row2 && col2 && diagonalTrue(bitMatrix.getByteMatrix(), x, y) && + qrCodeConfig.getDrawOptions().enableScale(QrCodeOptions.ExpandType.SIZE4)) { + // 四个相等 + bitMatrix.getByteMatrix().set(x + 1, y, 0); + bitMatrix.getByteMatrix().set(x + 1, y + 1, 0); + bitMatrix.getByteMatrix().set(x, y + 1, 0); + drawStyle.draw(g2, + leftPadding + x * infoSize, + topPadding + y * infoSize, + infoSize << 1, + infoSize << 1, + qrCodeConfig.getDrawOptions().getSize4Img()); + } else if (row2 && qrCodeConfig.getDrawOptions().enableScale(QrCodeOptions.ExpandType.ROW2)) { // 横向相同 + bitMatrix.getByteMatrix().set(x + 1, y, 0); + drawStyle.draw(g2, + leftPadding + x * infoSize, + topPadding + y * infoSize, + infoSize << 1, + infoSize, + qrCodeConfig.getDrawOptions().getRow2Img()); + } else if (col2 && qrCodeConfig.getDrawOptions().enableScale(QrCodeOptions.ExpandType.COL2)) { // 列的两个 + bitMatrix.getByteMatrix().set(x, y + 1, 0); + drawStyle.draw(g2, + leftPadding + x * infoSize, + topPadding + y * infoSize, + infoSize, + infoSize << 1, + qrCodeConfig.getDrawOptions().getCol2img()); + } else { + drawStyle.draw(g2, + leftPadding + x * infoSize, + topPadding + y * infoSize, + infoSize, + infoSize, + qrCodeConfig.getDrawOptions().getImg()); + } + } + } + } + g2.dispose(); + return qrCode; +} + + +private static boolean rightTrue(ByteMatrix byteMatrix, int x, int y) { + return x + 1 < byteMatrix.getWidth() && byteMatrix.get(x + 1, y) == 1; +} + +private static boolean belowTrue(ByteMatrix byteMatrix, int x, int y) { + return y + 1 < byteMatrix.getHeight() && byteMatrix.get(x, y + 1) == 1; +} + +// 对角是否相等 +private static boolean diagonalTrue(ByteMatrix byteMatrix, int x, int y) { + return byteMatrix.get(x + 1, y + 1) == 1; +} +``` + +#### 第三步. logo&背景的绘制 +> 到第二步,其实二维码就已经绘制完成了,二维码和背景都是在二维码这种图片上做文章,一个是往二维码上加图片,一个是将二维码绘制在另一张图片上 + +一个图片在另一个图片上绘制没啥技术含量,稍微特别点的就是logo的圆角和边框了 + +[《二维码服务拓展(支持logo,圆角logo,背景图,颜色配置)》](https://my.oschina.net/u/566591/blog/1491697) 较清晰的说了如何绘制圆角图片,圆角边框 + + +不想看上面博文的没啥关系,下面直接贴出代码,算是比较通用的方法了,与二维码项目本身没什么黏合 + +```java +/** + * 生成边框 + * + * @param image 原图 + * @param cornerRadius 角度 0表示直角 + * @param color 边框颜色 + * @return + */ +public static BufferedImage makeRoundBorder(BufferedImage image, + int cornerRadius, + Color color) { + int size = image.getWidth() / 15; + int w = image.getWidth() + size; + int h = image.getHeight() + size; + BufferedImage output = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + + Graphics2D g2 = output.createGraphics(); + g2.setComposite(AlphaComposite.Src); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2.setColor(color == null ? Color.WHITE : color); + g2.fill(new RoundRectangle2D.Float(0, 0, w, h, cornerRadius, cornerRadius)); + + g2.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_ATOP, 1.0f)); + g2.drawImage(image, size >> 1, size >> 1, null); + g2.dispose(); + + return output; +} + + +/** + * 生成圆角图片 + * + * @param image 原始图片 + * @param cornerRadius 圆角的弧度大小(根据实测效果,一般建议为图片宽度的1/4), 0表示直角 + * @return 返回圆角图 + */ +public static BufferedImage makeRoundedCorner(BufferedImage image, + int cornerRadius) { + int w = image.getWidth(); + int h = image.getHeight(); + BufferedImage output = new BufferedImage(w, h, + BufferedImage.TYPE_INT_ARGB); + + Graphics2D g2 = output.createGraphics(); + g2.setComposite(AlphaComposite.Src); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON); + g2.setColor(Color.WHITE); + g2.fill(new RoundRectangle2D.Float(0, 0, w, h, cornerRadius, + cornerRadius)); + + + g2.setComposite(AlphaComposite.SrcAtop); + g2.drawImage(image, 0, 0, null); + + g2.dispose(); + + return output; +} +``` + +与上一篇定制博文有一点区别的是,对背景图的支持进行了扩展,除了支持之前的设置二维码透明度,全覆盖背景图之外,又支持了在背景图的指定位置处进行绘制二维码,因为这一块确实没什么好讲的,干脆贴下代码好了 + +```java +/** + * 绘制背景图 + * + * @param source 二维码图 + * @param bgImgOptions 背景图信息 + * @return + */ +public static BufferedImage drawBackground(BufferedImage source, QrCodeOptions.BgImgOptions bgImgOptions) { + int sW = source.getWidth(); + int sH = source.getHeight(); + + // 背景的图宽高不应该小于原图 + int bgW = bgImgOptions.getBgW() < sW ? sW : bgImgOptions.getBgW(); + int bgH = bgImgOptions.getBgH() < sH ? sH : bgImgOptions.getBgH(); + + + // 背景图缩放 + BufferedImage bg = bgImgOptions.getBgImg(); + if (bg.getWidth() != bgW || bg.getHeight() != bgH) { + BufferedImage temp = new BufferedImage(bgW, bgH, BufferedImage.TYPE_INT_ARGB); + temp.getGraphics().drawImage(bg.getScaledInstance(bgW, bgH, Image.SCALE_SMOOTH) + , 0, 0, null); + bg = temp; + } + + Graphics2D g2d = bg.createGraphics(); + if (bgImgOptions.getBgImgStyle() == QrCodeOptions.BgImgStyle.FILL) { + // 选择一块区域进行填充 + g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_ATOP, 1.0f)); + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2d.drawImage(source, bgImgOptions.getStartX(), bgImgOptions.getStartY(), sW, sH, null); + } else { + // 覆盖方式 + int x = (bgW - sW) >> 1; + int y = (bgH - sH) >> 1; + g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_ATOP, bgImgOptions.getOpacity())); // 透明度, 避免看不到背景 + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2d.drawImage(source, x, y, sW, sH, null); + g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_ATOP, 1.0f)); + } + g2d.dispose(); + bg.flush(); + return bg; +} +``` + +## 测试 + +开发完了之后,就要开始愉快的进行测试了,测试一个全乎的 + +```java +@Test +public void testGenStyleCodeV2() { + String msg = "http://weixin.qq.com/r/FS9waAPEg178rUcL93oH"; + + try { + String logo = "logo.jpg"; + String bg = "qrbg.jpg"; + BufferedImage img = QrCodeGenWrapper.of(msg) + .setW(550) + .setDrawPreColor(0xff002fa7) // 宝石蓝 + .setDetectOutColor(0xff0000ff) + .setDetectInColor(Color.RED) + .setDetectImg("detect.png") + .setPadding(1) + .setErrorCorrection(ErrorCorrectionLevel.H) + .setLogo(logo) + .setLogoStyle(QrCodeOptions.LogoStyle.ROUND) + .setLogoBgColor(0xff00cc00) + .setLogoRate(15) + .setDrawStyle(QrCodeOptions.DrawStyle.IMAGE.name()) + .setDrawEnableScale(true) + .setDrawImg("xhrBase.jpg") + .setDrawRow2Img("xhrr2.jpeg") + .setDrawCol2Img("xhrc2.jpeg") + .setDrawSize4Img("xhrSize4.jpg") + .setBgStyle(QrCodeOptions.BgImgStyle.FILL) + .setBgImg(bg) + .setBgStartX(230) + .setBgStartY(330) + .asBufferedImage(); + + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ImageIO.write(img, "png", outputStream); + String img64 = Base64Util.encode(outputStream); + System.out.println(""); + } catch (Exception e) { + System.out.println("create qrcode error! e: " + e); + Assert.assertTrue(false); + } +} +``` + +演示case: + +![测试示例]( http://s3.mogucdn.com/mlcdn/c45406/170812_3b0jhcgedi4abe7kcjbja5l0276bd_1224x708.gif) + +一个最终定格的二维码 + +![定格图](https://static.oschina.net/uploads/img/201708/12175633_sOfz.png "生成二维码图") + + +### 说明 + +上面的改造,在实际使用时,建议多测试测试是否可以扫描出来,腾讯系列产品的二维码扫描特别给力,一般都能很迅速的识别,其他的就不好说了 + + +## 其他 + +相关博文 + +- [java 实现二维码生成工具类](https://my.oschina.net/u/566591/blog/872728) +- [zxing 二维码大白边一步一步修复指南](https://my.oschina.net/u/566591/blog/872770) +- [spring-boot & zxing 搭建二维码服务](https://my.oschina.net/u/566591/blog/1457164) +- [二维码服务拓展(支持logo,圆角logo,背景图,颜色配置)](https://my.oschina.net/u/566591/blog/1491697) + + +项目地址: [https://github.com/liuyueyi/quick-media](https://github.com/liuyueyi/quick-media) + + +个人博客:[一灰的个人博客](http://blog.zbang.online:8080) + +公众号获取更多: + +![个人信息](https://static.oschina.net/uploads/img/201708/12175649_wn2r.png "个人信息") + + +## 参考 + +- [二维码基础原理](http://cli.im/news/help/10601) + + + diff --git "a/docs/\346\217\222\344\273\266/\344\272\214\347\273\264\347\240\201/\344\272\214\347\273\264\347\240\201\347\232\204\345\237\272\347\241\200\346\234\215\345\212\241\346\213\223\345\261\225.md" "b/docs/\346\217\222\344\273\266/\344\272\214\347\273\264\347\240\201/\344\272\214\347\273\264\347\240\201\347\232\204\345\237\272\347\241\200\346\234\215\345\212\241\346\213\223\345\261\225.md" new file mode 100644 index 00000000..109e2107 --- /dev/null +++ "b/docs/\346\217\222\344\273\266/\344\272\214\347\273\264\347\240\201/\344\272\214\347\273\264\347\240\201\347\232\204\345\237\272\347\241\200\346\234\215\345\212\241\346\213\223\345\261\225.md" @@ -0,0 +1,577 @@ +# 二维码的基础服务拓展 +> zxing 提供了二维码一些列的功能,在日常生活中,可以发现很多二维码并不仅仅是简单的黑白矩形块,有的添加了文字,加了logo,定制颜色,背景等,本片博文则着手于此,进行基础服务的拓展 + +本片博文拓展的功能点: + +- 支持在二维码中间添加logo +- logo样式选择:支持圆角/直角logo,支持logo的边框选择 +- 二维码颜色选择(可自由将原来的黑白色进行替换) +- 支持背景图片 +- 支持探测图形的前置色选择 + +一个包含上面所有功能点的二维码如下图 + +![http://s2.mogucdn.com/mlcdn/c45406/170728_45a54147f26eh3lf1aiek04c1620h_300x300.png](http://s2.mogucdn.com/mlcdn/c45406/170728_45a54147f26eh3lf1aiek04c1620h_300x300.png) + +## 准备 +> 由于之前有一篇博文[《spring-boot & zxing 搭建二维码服务》](https://my.oschina.net/u/566591/blog/1457164) 较为消息的介绍了设计一个二维码服务的过程,因此这篇则不再整体设计上多做说明,主要的功能点将集中在以上几个功能点设计与实现上 + +源码地址: [https://github.com/liuyueyi/quick-media](https://github.com/liuyueyi/quick-media) + +这篇博文,将不对二维码生成的细节进行说明,某些地方如有疑惑(如二维码生成时的一些参数,渲染逻辑等)请直接查看代码,or百度谷歌,或者私聊也可。 + +下面简单说明一下这个工程中与二维码相关的几个类的作用 + +### 1. `QrCodeOptions.java` + +二维码的各种配置参数 + +### 2. `QrCodeGenWrapper.java` + +封装了二维码的参数设置和处理方法,通常来讲对于使用者而言,只需要使用这个类中的方法即可实现二维码的生成,如生成上面的二维码测试代码如下 + +```java +@Test +public void testGenColorCode() { + String msg = "https://my.oschina.net/u/566591/blog/1359432"; + // 根据本地文件生成待logo的二维码, 重新着色位置探测图像 + try { + String logo = "logo.jpg"; + String bg = "bg.png"; + BufferedImage img = QrCodeGenWrapper.of(msg) + .setW(300) + .setPreColor(0xff0000ff) + .setBgColor(0xffFFFF00) + .setDetectCornerPreColor(0xffff0000) + .setPadding(2) + .setLogo(logo) + .setLogoStyle(QrCodeOptions.LogoStyle.ROUND) + .setLogoBgColor(0xff00cc00) + .setBackground(bg) + .asBufferedImage(); + + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ImageIO.write(img, "png", outputStream); + System.out.println(Base64Util.encode(outputStream)); + } catch (Exception e) { + System.out.println("create qrcode error! e: " + e); + Assert.assertTrue(false); + } +} +``` + +### 3.`QrCodeUtil.java` + +二维码工具类,包括生成二维码矩阵信息,二维码图片渲染,输出`BufferedIamge`对象等 + + +### 4. `ImageUtil.java` + +图片处理辅助类,实现图片圆角化,添加边框,插入logo,绘制背景图等 + +--- + +## 设计与实现 + +### 1. 二维码颜色可配置 +> 二维码颜色的选择,主要在将二维码矩阵转换成图的时候,选择不同的颜色进行渲染即可,我们主要的代码将放在 `com.hust.hui.quickmedia.common.util.QrCodeUtil#toBufferedImage` 方法中 + +先看一下实现逻辑 + +```java +/** + * 根据二维码配置 & 二维码矩阵生成二维码图片 + * + * @param qrCodeConfig + * @param bitMatrix + * @return + * @throws IOException + */ +public static BufferedImage toBufferedImage(QrCodeOptions qrCodeConfig, BitMatrixEx bitMatrix) throws IOException { + int qrCodeWidth = bitMatrix.getWidth(); + int qrCodeHeight = bitMatrix.getHeight(); + BufferedImage qrCode = new BufferedImage(qrCodeWidth, qrCodeHeight, BufferedImage.TYPE_INT_RGB); + + for (int x = 0; x < qrCodeWidth; x++) { + for (int y = 0; y < qrCodeHeight; y++) { + qrCode.setRGB(x, y, + bitMatrix.get(x, y) ? + qrCodeConfig.getMatrixToImageConfig().getPixelOnColor() : + qrCodeConfig.getMatrixToImageConfig().getPixelOffColor()); + } + } + + ... +} +``` + +**注意** + +`BitMatrixEx` 是 `com.google.zxing.common.BitMatrix` 的拓展,后面说明为什么这么做, + +此处知晓 `com.hust.hui.quickmedia.common.qrcode.BitMatrixEx#get` 等同于 `com.google.zxing.common.BitMatrix#get`即可 + +**说明** + +- 上面的逻辑比较清晰,先创建一个置顶大小的图像,然后遍历 `bitMatrix`,对图像进行着色 + +- `bitMatrix.get(x, y) == true` 表示该处为二维码的有效信息(这个是在二维码生成时决定,zxing的二维码生成逻辑负责生成BitMatrix对象,原理此处省略,因为我也没仔细研究),然后涂上配置的前置色;否则表示空白背景,涂上背景色即可 + + +### 2. 位置探测图行可配置 +> 位置探测图形就是二维码的左上角,右上角,左下角的三个矩形框(前面途中的三个红框),用于定位二维码使用,这里的实现确保它的颜色可以与二维码的前置色不同 + +经过上面的二维码颜色渲染,很容易就可以想到,在二维码的最终渲染时,对位置探测图形采用不同的颜色进行渲染即可,所以渲染代码如下 + + +```java +/** + * 根据二维码配置 & 二维码矩阵生成二维码图片 + * + * @param qrCodeConfig + * @param bitMatrix + * @return + * @throws IOException + */ +public static BufferedImage toBufferedImage(QrCodeOptions qrCodeConfig, BitMatrixEx bitMatrix) throws IOException { + int qrCodeWidth = bitMatrix.getWidth(); + int qrCodeHeight = bitMatrix.getHeight(); + BufferedImage qrCode = new BufferedImage(qrCodeWidth, qrCodeHeight, BufferedImage.TYPE_INT_RGB); + + for (int x = 0; x < qrCodeWidth; x++) { + for (int y = 0; y < qrCodeHeight; y++) { + if (bitMatrix.isDetectCorner(x, y)) { // 着色位置探测图形 + qrCode.setRGB(x, y, + bitMatrix.get(x, y) ? + qrCodeConfig.getDetectCornerColor().getPixelOnColor() : + qrCodeConfig.getDetectCornerColor().getPixelOffColor()); + } else { // 着色二维码主题 + qrCode.setRGB(x, y, + bitMatrix.get(x, y) ? + qrCodeConfig.getMatrixToImageConfig().getPixelOnColor() : + qrCodeConfig.getMatrixToImageConfig().getPixelOffColor()); + } + } + } + + .... +} +``` + +相比较与之前,在遍历逻辑中,多了一个是否为位置探测图形的分支判断 + +```java +if (bitMatrix.isDetectCorner(x, y)) { // 着色位置探测图形 + qrCode.setRGB(x, y, + bitMatrix.get(x, y) ? + qrCodeConfig.getDetectCornerColor().getPixelOnColor() : + qrCodeConfig.getDetectCornerColor().getPixelOffColor()); +} +``` + +所以我们的问题就是如何判断(x,y)坐标对应的位置是否为位置探测图形? + +#### 位置探测图形判定 + +这个判定的逻辑,就需要深入到二维码矩阵的生成逻辑中,直接给出对应代码位置 + +```java +// Embed basic patterns +// The basic patterns are: +// - Position detection patterns +// - Timing patterns +// - Dark dot at the left bottom corner +// - Position adjustment patterns, if need be +com.google.zxing.qrcode.encoder.MatrixUtil#embedBasicPatterns + + +// 确定位置探测图形的方法 +com.google.zxing.qrcode.encoder.MatrixUtil#embedPositionDetectionPatternsAndSeparators + +// 自适应调整矩阵的方法 +com.google.zxing.qrcode.encoder.MatrixUtil#maybeEmbedPositionAdjustmentPatterns +``` + +直接看代码,会发现位置探测图形的二维数组如下 + +```java +private static final int[][] POSITION_DETECTION_PATTERN = { + {1, 1, 1, 1, 1, 1, 1}, + {1, 0, 0, 0, 0, 0, 1}, + {1, 0, 1, 1, 1, 0, 1}, + {1, 0, 1, 1, 1, 0, 1}, + {1, 0, 1, 1, 1, 0, 1}, + {1, 0, 0, 0, 0, 0, 1}, + {1, 1, 1, 1, 1, 1, 1}, +}; + +private static final int[][] POSITION_ADJUSTMENT_PATTERN = { + {1, 1, 1, 1, 1}, + {1, 0, 0, 0, 1}, + {1, 0, 1, 0, 1}, + {1, 0, 0, 0, 1}, + {1, 1, 1, 1, 1}, +}; +``` + +到这里,我们的判断就比较清晰了,位置探测图形有两种规格,5 or 7 + +在看具体的判定逻辑之前,先看 `BitMatrixEx`增强类,可以判定(x,y)坐标处是否为位置探测图形,内部判定逻辑和 `BitMatrix`中是否为二维码有效信息的判定一致 + +```java +@Getter +@Setter +public class BitMatrixEx { + private final int width; + private final int height; + private final int rowSize; + private final int[] bits; + + + private BitMatrix bitMatrix; + + public BitMatrixEx(BitMatrix bitMatrix) { + this(bitMatrix.getWidth(), bitMatrix.getHeight()); + this.bitMatrix = bitMatrix; + + } + + private BitMatrixEx(int width, int height) { + if (width < 1 || height < 1) { + throw new IllegalArgumentException("Both dimensions must be greater than 0"); + } + + this.width = width; + this.height = height; + this.rowSize = (width + 31) / 32; + bits = new int[rowSize * height]; + } + + + + public void setRegion(int left, int top, int width, int height) { + int right = left + width; + int bottom = top + height; + + for (int y = top; y < bottom; y++) { + int offset = y * rowSize; + for (int x = left; x < right; x++) { + bits[offset + (x / 32)] |= 1 << (x & 0x1f); + } + } + } + + + public boolean get(int x, int y) { + return bitMatrix.get(x, y); + } + + + public boolean isDetectCorner(int x, int y) { + int offset = y * rowSize + (x / 32); + return ((bits[offset] >>> (x & 0x1f)) & 1) != 0; + } +} +``` + + +**位置判定逻辑** + +位置判定逻辑在 `com.hust.hui.quickmedia.common.util.QrCodeUtil#renderResult` 方法中,简单说一下这个方法的作用 + +- 根据 `com.google.zxing.qrcode.encoder.QRCode` 生成 `BitMatrixEx` 对象 +- 内部实现二维码白边的修复(详情参考博文:[《zxing 二维码大白边一步一步修复指南》](https://my.oschina.net/u/566591/blog/872770)) +- 内部实现位置探测图形的判定逻辑 + +直接看判定逻辑 + +```java +// 获取位置探测图形的size,根据源码分析,有两种size的可能 +// {@link com.google.zxing.qrcode.encoder.MatrixUtil.embedPositionDetectionPatternsAndSeparators} +ByteMatrix input = qrCode.getMatrix(); +// 因为位置探测图形的下一位必然是0,所以下面的一行可以判定选择的是哪种规格的位置判定 +int detectCornerSize = input.get(0, 5) == 1 ? 7 : 5; + +for (int inputY = 0, outputY = topPadding; inputY < inputHeight; inputY++, outputY += multiple) { + // Write the contents of this row of the barcode + for (int inputX = 0, outputX = leftPadding; inputX < inputWidth; inputX++, outputX += multiple) { + if (input.get(inputX, inputY) == 1) { + // 二维码的有效信息设置(即传统二维码中黑色局域的确定) + output.setRegion(outputX, outputY, multiple, multiple); + } + + + // 设置三个位置探测图形 + if (inputX < detectCornerSize && inputY < detectCornerSize // 左上角 + || (inputX < detectCornerSize && inputY >= inputHeight - detectCornerSize) // 左下脚 + || (inputX >= inputWidth - detectCornerSize && inputY < detectCornerSize)) { // 右上角 + res.setRegion(outputX, outputY, multiple, multiple); + } + } +} + +``` + +### 3. 背景图支持 +> 前面两个涉及到二维码本身的修改,接下来的背景 & logo则基本上无二维码无关,只是图片的操作而已,背景图支持,即将背景图作为图层,将二维码渲染在正中间即可 + +对于图片的覆盖,直接借用 java.awt 包下的工具类即可实现 + +```java +/** + * 绘制背景图 + * + * @param source 原图 + * @param background 背景图 + * @param bgW 背景图宽 + * @param bgH 背景图高 + * @return + * @throws IOException + */ +public static BufferedImage drawBackground(BufferedImage source, String background, int bgW, int bgH) throws IOException { + int sW = source.getWidth(); + int sH = source.getHeight(); + + + // 背景的图宽高不应该小于原图 + if (bgW < sW) { + bgW = sW; + } + + if (bgH < sH) { + bgH = sH; + } + + + // 获取背景图 + BufferedImage bg = getImageByPath(background); + if (bg.getWidth() != bgW || bg.getHeight() != bgH) { // 需要缩放 + BufferedImage temp = new BufferedImage(bgW, bgH, BufferedImage.TYPE_INT_ARGB); + temp.getGraphics().drawImage(bg.getScaledInstance(bgW, bgH, Image.SCALE_SMOOTH) + , 0, 0, null); + bg = temp; + } + + + // 绘制背景图 + int x = (bgW - sW) >> 1; + int y = (bgH - sH) >> 1; + Graphics2D g2d = bg.createGraphics(); + g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_ATOP, 0.8f)); // 透明度, 避免看不到背景 + g2d.drawImage(source, x, y, sW, sH, null); + g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_ATOP, 1.0f)); + g2d.dispose(); + bg.flush(); + return bg; +} + +``` + +简单说一下上面的实现逻辑 + +- 获取背景图 +- 根据置顶的背景图大小,对原背景图进行缩放 +- 将目标图片(二维码)绘制在背景图正中间 + +其中,我们对二维码的覆盖设置了透明度为0.8,确保不会完全覆盖背景图,导致完全看不到背景是什么,此处如有其他的需求场景可以进行可配置化处理 + + +### 4. logo支持 +> 其实logo的支持和背景的支持逻辑基本没什么差别,都是将一个图绘制在另一个图上 + +具体的实现如下, 先无视logo样式的选择问题 + +```java +/** + * 在图片中间,插入圆角的logo + * + * @param qrCode 原图 + * @param logo logo地址 + * @param logoStyle logo 的样式 (圆角, 直角) + * @param logoBgColor logo的背景色 + * @throws IOException + */ +public static void insertLogo(BufferedImage qrCode, + String logo, + QrCodeOptions.LogoStyle logoStyle, + Color logoBgColor) throws IOException { + int QRCODE_WIDTH = qrCode.getWidth(); + int QRCODE_HEIGHT = qrCode.getHeight(); + + // 获取logo图片 + BufferedImage bf = getImageByPath(logo); + int boderSize = bf.getWidth() / 15; + // 生成圆角边框logo + bf = makeRoundBorder(bf, logoStyle, boderSize, logoBgColor); // 边距为二维码图片的1/15 + + // logo的宽高 + int w = bf.getWidth() > QRCODE_WIDTH * 2 / 10 ? QRCODE_WIDTH * 2 / 10 : bf.getWidth(); + int h = bf.getHeight() > QRCODE_HEIGHT * 2 / 10 ? QRCODE_HEIGHT * 2 / 10 : bf.getHeight(); + + // 插入LOGO + Graphics2D graph = qrCode.createGraphics(); + + int x = (QRCODE_WIDTH - w) >> 1 ; + int y = (QRCODE_HEIGHT - h) >> 1; + + graph.drawImage(bf, x, y, w, h, null); + graph.dispose(); + bf.flush(); +} +``` + +上面的主要逻辑,其实没啥区别,接下来主要关心的则是圆角图形生成以及边框的支持 + +### 5. 圆角图形 +> 生成圆角图片是一个非常常见的需求 + +先借用`new RoundRectangle2D.Float(0, 0, w, h, cornerRadius, + cornerRadius)`绘制一个圆角的画布出来 + +将原图绘制在画布上即可 + +```java +/** + * 生成圆角图片 + * + * @param image 原始图片 + * @param cornerRadius 圆角的弧度 + * @return 返回圆角图 + */ +public static BufferedImage makeRoundedCorner(BufferedImage image, + int cornerRadius) { + int w = image.getWidth(); + int h = image.getHeight(); + BufferedImage output = new BufferedImage(w, h, + BufferedImage.TYPE_INT_ARGB); + + Graphics2D g2 = output.createGraphics(); + + // This is what we want, but it only does hard-clipping, i.e. aliasing + // g2.setClip(new RoundRectangle2D ...) + + // so instead fake soft-clipping by first drawing the desired clip shape + // in fully opaque white with antialiasing enabled... + g2.setComposite(AlphaComposite.Src); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON); + g2.setColor(Color.WHITE); + g2.fill(new RoundRectangle2D.Float(0, 0, w, h, cornerRadius, + cornerRadius)); + + // ... then compositing the image on top, + // using the white shape from above as alpha source + g2.setComposite(AlphaComposite.SrcAtop); + g2.drawImage(image, 0, 0, null); + + g2.dispose(); + + return output; +} +``` + +### 6. 圆角边框的图片 +> 上面实现圆角图片之后,再考虑生成一个带圆角边框的图片就很简单了,直接绘制一个大一号的存色边框,然后将圆角图片绘制上去即可 + +```java +/** + *

+ * 生成圆角图片 & 圆角边框 + * + * @param image 原图 + * @param logoStyle 圆角的角度 + * @param size 边框的边距 + * @param color 边框的颜色 + * @return 返回带边框的圆角图 + */ +public static BufferedImage makeRoundBorder(BufferedImage image, + QrCodeOptions.LogoStyle logoStyle, + int size, Color color) { + // 将图片变成圆角 + int cornerRadius = 0; + if (logoStyle == QrCodeOptions.LogoStyle.ROUND) { + cornerRadius = image.getWidth() / 4; + image = makeRoundedCorner(image, cornerRadius); + } + + int w = image.getWidth() + size; + int h = image.getHeight() + size; + BufferedImage output = new BufferedImage(w, h, + BufferedImage.TYPE_INT_ARGB); + + Graphics2D g2 = output.createGraphics(); + g2.setComposite(AlphaComposite.Src); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON); + g2.setColor(color == null ? Color.WHITE : color); + g2.fill(new RoundRectangle2D.Float(0, 0, w, h, cornerRadius, + cornerRadius)); + + // ... then compositing the image on top, + // using the white shape from above as alpha source +// g2.setComposite(AlphaComposite.SrcAtop); + g2.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_ATOP, 1.0f)); + g2.drawImage(image, size / 2, size / 2, null); + g2.dispose(); + + return output; +} +``` + + +## 测试 + +上面分别对每一个点进行了实现并加以简单说明,最后就是需要将上面的都串起来进行测试了,因为我们的工程是在前面已经搭建好的二维码服务上进行的,所以测试代码也比较简单,如下 + +```java +@Test +public void testGenColorCode() { + String msg = "https://my.oschina.net/u/566591/blog/1359432"; + // 根据本地文件生成待logo的二维码, 重新着色位置探测图像 + try { + String logo = "logo.jpg"; + String bg = "bg.png"; + BufferedImage img = QrCodeGenWrapper.of(msg) + .setW(300) + .setPreColor(0xff0000ff) + .setBgColor(0xffFFFF00) + .setDetectCornerPreColor(0xffff0000) + .setPadding(2) + .setLogo(logo) + .setLogoStyle(QrCodeOptions.LogoStyle.ROUND) + .setLogoBgColor(0xff00cc00) + .setBackground(bg) + .asBufferedImage(); + + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ImageIO.write(img, "png", outputStream); + System.out.println(Base64Util.encode(outputStream)); + } catch (Exception e) { + System.out.println("create qrcode error! e: " + e); + Assert.assertTrue(false); + } +} +``` + +测试执行示意图 + +![http://s2.mogucdn.com/mlcdn/c45406/170728_2lebbba9b47037cc0g03hd42hf6ga_1224x639.gif](http://s2.mogucdn.com/mlcdn/c45406/170728_2lebbba9b47037cc0g03hd42hf6ga_1224x639.gif) + +--- +## 其他 + +项目源码: [https://github.com/liuyueyi/quick-media](https://github.com/liuyueyi/quick-media) + +相关博文: + +- [zxing 二维码大白边一步一步修复指南](https://my.oschina.net/u/566591/blog/872770) +- [spring-boot & zxing 搭建二维码服务](https://my.oschina.net/u/566591/blog/1457164) + + +个人博客:[一灰的个人博客](http://zbang.online:8080/articles/2017/07/18/1500369136069.html) + +公众号获取更多: + +![https://static.oschina.net/uploads/img/201707/09205944_0PzS.jpg](https://static.oschina.net/uploads/img/201707/09205944_0PzS.jpg) \ No newline at end of file diff --git "a/docs/\346\217\222\344\273\266/\344\272\214\347\273\264\347\240\201/\344\275\277\347\224\250zxing\346\217\220\344\276\233\344\272\214\347\273\264\347\240\201\347\224\237\346\210\220\350\247\243\346\236\220\346\234\215\345\212\241.md" "b/docs/\346\217\222\344\273\266/\344\272\214\347\273\264\347\240\201/\344\275\277\347\224\250zxing\346\217\220\344\276\233\344\272\214\347\273\264\347\240\201\347\224\237\346\210\220\350\247\243\346\236\220\346\234\215\345\212\241.md" new file mode 100644 index 00000000..af7e0692 --- /dev/null +++ "b/docs/\346\217\222\344\273\266/\344\272\214\347\273\264\347\240\201/\344\275\277\347\224\250zxing\346\217\220\344\276\233\344\272\214\347\273\264\347\240\201\347\224\237\346\210\220\350\247\243\346\236\220\346\234\215\345\212\241.md" @@ -0,0 +1,846 @@ +# 使用zxing提供二维码生成解析服务 + +> 搭建一个二维码的生成 & 解析服务, 使用java web对外提供http调用,返回base64格式的二维码图片 + +## 1. 背景&准备 +> 二维码生成场景实在是太多了,背景都没啥好说的... + +### 采用的技术 + +- zxing : 实现二维码的生成 & 解析 +- spring-boot: 提供http服务接口 +- jdk base64 : 对图片进行base64编码返回 +- awt : 插入logo + +### 测试case + +二维码生成除了传入基本的内容之外,有很多可以配置的参数,比如背景色,前置色,大小,logo,边框...,显然这种多参数配置的情况,我们会采用Builder设计模式来处理,可以看下最终的测试代码如下 + +```java +/** + * 测试二维码 + */ +@Test +public void testGenQrCode() { + String msg = "https://my.oschina.net/u/566591/blog/1359432"; + + try { + + boolean ans = QrCodeGenWrapper.of(msg).asFile("src/test/qrcode/gen.png"); + System.out.println(ans); + } catch (Exception e) { + System.out.println("create qrcode error! e: " + e); + Assert.assertTrue(false); + } + + + //生成红色的二维码 300x300, 无边框 + try { + boolean ans = QrCodeGenWrapper.of(msg) + .setW(300) + .setPreColor(0xffff0000) + .setBgColor(0xffffffff) + .setPadding(0) + .asFile("src/test/qrcode/gen_300x300.png"); + System.out.println(ans); + } catch (Exception e) { + System.out.println("create qrcode error! e: " + e); + Assert.assertTrue(false); + } + + + // 生成带logo的二维码 + try { + String logo = "https://static.oschina.net/uploads/user/283/566591_100.jpeg"; + boolean ans = QrCodeGenWrapper.of(msg) + .setW(300) + .setPreColor(0xffff0000) + .setBgColor(0xffffffff) + .setPadding(0) + .setLogo(logo) + .setLogoStyle(QrCodeOptions.LogoStyle.ROUND) + .asFile("src/test/qrcode/gen_300x300_logo.png"); + System.out.println(ans); + } catch (Exception e) { + System.out.println("create qrcode error! e: " + e); + Assert.assertTrue(false); + } + + + // 根据本地文件生成待logo的二维码 + try { + String logo = "logo.jpg"; + boolean ans = QrCodeGenWrapper.of(msg) + .setW(300) + .setPreColor(0xffff0000) + .setBgColor(0xffffffff) + .setPadding(0) + .setLogo(logo) + .asFile("src/test/qrcode/gen_300x300_logo_v2.png"); + System.out.println(ans); + } catch (Exception e) { + System.out.println("create qrcode error! e: " + e); + Assert.assertTrue(false); + } +} +``` + +## 2. 设计与实现 + +### 1. 配置参数: `QrCodeOptions` + +根据最常用的规则,目前提供以下可选的配置项 + +- 输入内容 +- logo +- logo的样式 +- 宽高 +- 前置色,背景色 +- 输出图片格式 +- 内容编码 + +```java +@Data +public class QrCodeOptions { + /** + * 塞入二维码的信息 + */ + private String msg; + + + /** + * 二维码中间的logo + */ + private String logo; + + + /** + * logo的样式, 目前支持圆角+普通 + */ + private LogoStyle logoStyle; + + + /** + * 生成二维码的宽 + */ + private Integer w; + + + /** + * 生成二维码的高 + */ + private Integer h; + + + /** + * 生成二维码的颜色 + */ + private MatrixToImageConfig matrixToImageConfig; + + + private Map hints; + + + /** + * 生成二维码图片的格式 png, jpg + */ + private String picType; + + + public enum LogoStyle { + ROUND, + NORMAL; + } +} +``` + +从上面的配置来看,有较多其实是与zxing进行打交道的,直接对使用者而言,有点不太友好,下面可以看下我们的包装类 + +### 2. 包装类: `QrCodeGenWrapper` +> 对外提供二维码生成的主要入口,从我们的设计来看,通过`of(content)` 来创建一个builder对象,并设置二维码的内容,然后可以设置builder中的参数,来选择最终的二维码配置规则 + + +提供三中输出方式: + +- BufferImage 对象 : 适用于对二维码进行再次处理的场景 +- 二维码图片文件 : 适用于本地生成 +- base64编码的二维码字符串 : 适用于网络接口调用 + + +下面的实现比较简单,唯一需要注意的就是组装 `QrCodeOptions` 参数的默认值问题 + +```java +public class QrCodeGenWrapper { + public static Builder of(String content) { + return new Builder().setMsg(content); + } + + + private static BufferedImage asBufferedImage(QrCodeOptions qrCodeConfig) throws WriterException, IOException { + BitMatrix bitMatrix = QrCodeUtil.encode(qrCodeConfig); + return QrCodeUtil.toBufferedImage(qrCodeConfig, bitMatrix); + } + + private static String asString(QrCodeOptions qrCodeOptions) throws WriterException, IOException { + BufferedImage bufferedImage = asBufferedImage(qrCodeOptions); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ImageIO.write(bufferedImage, qrCodeOptions.getPicType(), outputStream); + return Base64Util.encode(outputStream); + } + + private static boolean asFile(QrCodeOptions qrCodeConfig, String absFileName) throws WriterException, IOException { + File file = new File(absFileName); + FileUtil.mkDir(file); + + BufferedImage bufferedImage = asBufferedImage(qrCodeConfig); + if (!ImageIO.write(bufferedImage, qrCodeConfig.getPicType(), file)) { + throw new IOException("save qrcode image error!"); + } + + return true; + } + + + @ToString + public static class Builder { + private static final MatrixToImageConfig DEFAULT_CONFIG = new MatrixToImageConfig(); + + /** + * The message to put into QrCode + */ + private String msg; + + + /** + * qrcode center logo + */ + private String logo; + + + /** + * logo的样式 + */ + private QrCodeOptions.LogoStyle logoStyle = QrCodeOptions.LogoStyle.NORMAL; + + + /** + * qrcode image width + */ + private Integer w; + + + /** + * qrcode image height + */ + private Integer h; + + + /** + * qrcode bgcolor, default white + */ + private Integer bgColor; + + + /** + * qrcode msg color, default black + */ + private Integer preColor; + + + /** + * qrcode message's code, default UTF-8 + */ + private String code = "utf-8"; + + + /** + * 0 - 4 + */ + private Integer padding; + + + /** + * error level, default H + */ + private ErrorCorrectionLevel errorCorrection = ErrorCorrectionLevel.H; + + + /** + * output qrcode image type, default png + */ + private String picType = "png"; + + + public String getMsg() { + return msg; + } + + public Builder setMsg(String msg) { + this.msg = msg; + return this; + } + + public Builder setLogo(String logo) { + this.logo = logo; + return this; + } + + + public Builder setLogoStyle(QrCodeOptions.LogoStyle logoStyle) { + this.logoStyle = logoStyle; + return this; + } + + + public Integer getW() { + return w == null ? (h == null ? 200 : h) : w; + } + + public Builder setW(Integer w) { + if (w != null && w <= 0) { + throw new IllegalArgumentException("生成二维码的宽必须大于0"); + } + this.w = w; + return this; + } + + public Integer getH() { + return h == null ? (w == null ? 200 : w) : h; + } + + public Builder setH(Integer h) { + if (h != null && h <= 0) { + throw new IllegalArgumentException("生成功能二维码的搞必须大于0"); + } + this.h = h; + return this; + } + + public Integer getBgColor() { + return bgColor == null ? MatrixToImageConfig.WHITE : bgColor; + } + + public Builder setBgColor(Integer bgColor) { + this.bgColor = bgColor; + return this; + } + + public Integer getPreColor() { + return preColor == null ? MatrixToImageConfig.BLACK : preColor; + } + + public Builder setPreColor(Integer preColor) { + this.preColor = preColor; + return this; + } + + public Builder setCode(String code) { + this.code = code; + return this; + } + + public Integer getPadding() { + if (padding == null) { + return 1; + } + + if (padding < 0) { + return 0; + } + + if (padding > 4) { + return 4; + } + + return padding; + } + + public Builder setPadding(Integer padding) { + this.padding = padding; + return this; + } + + public Builder setPicType(String picType) { + this.picType = picType; + return this; + } + + public void setErrorCorrection(ErrorCorrectionLevel errorCorrection) { + this.errorCorrection = errorCorrection; + } + + private void validate() { + if (msg == null || msg.length() == 0) { + throw new IllegalArgumentException("生成二维码的内容不能为空!"); + } + } + + + private QrCodeOptions build() { + this.validate(); + + QrCodeOptions qrCodeConfig = new QrCodeOptions(); + qrCodeConfig.setMsg(getMsg()); + qrCodeConfig.setH(getH()); + qrCodeConfig.setW(getW()); + qrCodeConfig.setLogo(logo); + qrCodeConfig.setLogoStyle(logoStyle); + qrCodeConfig.setPicType(picType); + + Map hints = new HashMap<>(3); + hints.put(EncodeHintType.ERROR_CORRECTION, errorCorrection); + hints.put(EncodeHintType.CHARACTER_SET, code); + hints.put(EncodeHintType.MARGIN, this.getPadding()); + qrCodeConfig.setHints(hints); + + + MatrixToImageConfig config; + if (getPreColor() == MatrixToImageConfig.BLACK + && getBgColor() == MatrixToImageConfig.WHITE) { + config = DEFAULT_CONFIG; + } else { + config = new MatrixToImageConfig(getPreColor(), getBgColor()); + } + qrCodeConfig.setMatrixToImageConfig(config); + + + return qrCodeConfig; + } + + + public String asString() throws IOException, WriterException { + return QrCodeGenWrapper.asString(build()); + } + + + public BufferedImage asBufferedImage() throws IOException, WriterException { + return QrCodeGenWrapper.asBufferedImage(build()); + } + + + public boolean asFile(String absFileName) throws IOException, WriterException { + return QrCodeGenWrapper.asFile(build(), absFileName); + } + } +} +``` + +### 二维码生成工具类 : `QrCodeUtil` + +下面这个工具类看着比较复杂,其实大部分代码是从 `com.google.zxing.qrcode.QRCodeWriter#encode(String, BarcodeFormat, int, int, Map)` 抠出来的 + +主要是为了解决二维码的白边问题,关于这个大白边问题,可以参看我之前的一篇博文 [《zxing 二维码大白边一步一步修复指南》](https://my.oschina.net/u/566591/blog/872770) + + +```java +@Slf4j +public class QrCodeUtil { + + private static final int QUIET_ZONE_SIZE = 4; + + + /** + * 对 zxing 的 QRCodeWriter 进行扩展, 解决白边过多的问题 + *

+ * 源码参考 {@link com.google.zxing.qrcode.QRCodeWriter#encode(String, BarcodeFormat, int, int, Map)} + */ + public static BitMatrix encode(QrCodeOptions qrCodeConfig) throws WriterException { + ErrorCorrectionLevel errorCorrectionLevel = ErrorCorrectionLevel.L; + int quietZone = 1; + if (qrCodeConfig.getHints() != null) { + if (qrCodeConfig.getHints().containsKey(EncodeHintType.ERROR_CORRECTION)) { + errorCorrectionLevel = ErrorCorrectionLevel.valueOf(qrCodeConfig.getHints().get(EncodeHintType.ERROR_CORRECTION).toString()); + } + if (qrCodeConfig.getHints().containsKey(EncodeHintType.MARGIN)) { + quietZone = Integer.parseInt(qrCodeConfig.getHints().get(EncodeHintType.MARGIN).toString()); + } + + if (quietZone > QUIET_ZONE_SIZE) { + quietZone = QUIET_ZONE_SIZE; + } else if (quietZone < 0) { + quietZone = 0; + } + } + + QRCode code = Encoder.encode(qrCodeConfig.getMsg(), errorCorrectionLevel, qrCodeConfig.getHints()); + return renderResult(code, qrCodeConfig.getW(), qrCodeConfig.getH(), quietZone); + } + + + /** + * 对 zxing 的 QRCodeWriter 进行扩展, 解决白边过多的问题 + *

+ * 源码参考 {@link com.google.zxing.qrcode.QRCodeWriter#renderResult(QRCode, int, int, int)} + * + * @param code + * @param width + * @param height + * @param quietZone 取值 [0, 4] + * @return + */ + private static BitMatrix renderResult(QRCode code, int width, int height, int quietZone) { + ByteMatrix input = code.getMatrix(); + if (input == null) { + throw new IllegalStateException(); + } + + // xxx 二维码宽高相等, 即 qrWidth == qrHeight + int inputWidth = input.getWidth(); + int inputHeight = input.getHeight(); + int qrWidth = inputWidth + (quietZone * 2); + int qrHeight = inputHeight + (quietZone * 2); + + + // 白边过多时, 缩放 + int minSize = Math.min(width, height); + int scale = calculateScale(qrWidth, minSize); + if (scale > 0) { + if (log.isDebugEnabled()) { + log.debug("qrCode scale enable! scale: {}, qrSize:{}, expectSize:{}x{}", scale, qrWidth, width, height); + } + + int padding, tmpValue; + // 计算边框留白 + padding = (minSize - qrWidth * scale) / QUIET_ZONE_SIZE * quietZone; + tmpValue = qrWidth * scale + padding; + if (width == height) { + width = tmpValue; + height = tmpValue; + } else if (width > height) { + width = width * tmpValue / height; + height = tmpValue; + } else { + height = height * tmpValue / width; + width = tmpValue; + } + } + + int outputWidth = Math.max(width, qrWidth); + int outputHeight = Math.max(height, qrHeight); + + int multiple = Math.min(outputWidth / qrWidth, outputHeight / qrHeight); + int leftPadding = (outputWidth - (inputWidth * multiple)) / 2; + int topPadding = (outputHeight - (inputHeight * multiple)) / 2; + + BitMatrix output = new BitMatrix(outputWidth, outputHeight); + + for (int inputY = 0, outputY = topPadding; inputY < inputHeight; inputY++, outputY += multiple) { + // Write the contents of this row of the barcode + for (int inputX = 0, outputX = leftPadding; inputX < inputWidth; inputX++, outputX += multiple) { + if (input.get(inputX, inputY) == 1) { + output.setRegion(outputX, outputY, multiple, multiple); + } + } + } + + return output; + } + + + /** + * 如果留白超过15% , 则需要缩放 + * (15% 可以根据实际需要进行修改) + * + * @param qrCodeSize 二维码大小 + * @param expectSize 期望输出大小 + * @return 返回缩放比例, <= 0 则表示不缩放, 否则指定缩放参数 + */ + private static int calculateScale(int qrCodeSize, int expectSize) { + if (qrCodeSize >= expectSize) { + return 0; + } + + int scale = expectSize / qrCodeSize; + int abs = expectSize - scale * qrCodeSize; + if (abs < expectSize * 0.15) { + return 0; + } + + return scale; + } + + + + /** + * 根据二维码配置 & 二维码矩阵生成二维码图片 + * + * @param qrCodeConfig + * @param bitMatrix + * @return + * @throws IOException + */ + public static BufferedImage toBufferedImage(QrCodeOptions qrCodeConfig, BitMatrix bitMatrix) throws IOException { + int qrCodeWidth = bitMatrix.getWidth(); + int qrCodeHeight = bitMatrix.getHeight(); + BufferedImage qrCode = new BufferedImage(qrCodeWidth, qrCodeHeight, BufferedImage.TYPE_INT_RGB); + + for (int x = 0; x < qrCodeWidth; x++) { + for (int y = 0; y < qrCodeHeight; y++) { + qrCode.setRGB(x, y, + bitMatrix.get(x, y) ? + qrCodeConfig.getMatrixToImageConfig().getPixelOnColor() : + qrCodeConfig.getMatrixToImageConfig().getPixelOffColor()); + } + } + + // 插入logo + if (!(qrCodeConfig.getLogo() == null || "".equals(qrCodeConfig.getLogo()))) { + ImageUtil.insertLogo(qrCode, qrCodeConfig.getLogo(), qrCodeConfig.getLogoStyle()); + } + + // 若二维码的实际宽高和预期的宽高不一致, 则缩放 + int realQrCodeWidth = qrCodeConfig.getW(); + int realQrCodeHeight = qrCodeConfig.getH(); + if (qrCodeWidth != realQrCodeWidth || qrCodeHeight != realQrCodeHeight) { + BufferedImage tmp = new BufferedImage(realQrCodeWidth, realQrCodeHeight, BufferedImage.TYPE_INT_RGB); + tmp.getGraphics().drawImage( + qrCode.getScaledInstance(realQrCodeWidth, realQrCodeHeight, + Image.SCALE_SMOOTH), 0, 0, null); + qrCode = tmp; + } + + return qrCode; + } + +} +``` + + +### 4. logo的插入辅助类: `ImageUtil` + +zxing本身是不支持生成待logo的二维码的,这里我们借用awt对将logo绘制在生成的二维码图片上 + +这里提供了圆角图片生成,边框生成,插入logo三个功能 + +涉及到绘图的逻辑,也没啥可说的,基本上的套路都一样 + +```java +public class ImageUtil { + + /** + * 在图片中间,插入圆角的logo + * + * @param qrCode 原图 + * @param logo logo地址 + * @throws IOException + */ + public static void insertLogo(BufferedImage qrCode, String logo, QrCodeOptions.LogoStyle logoStyle) throws IOException { + int QRCODE_WIDTH = qrCode.getWidth(); + int QRCODE_HEIGHT = qrCode.getHeight(); + + // 获取logo图片 + BufferedImage bf = getImageByPath(logo); + int size = bf.getWidth() > QRCODE_WIDTH * 2 / 10 ? QRCODE_WIDTH * 2 / 50 : bf.getWidth() / 5; + bf = ImageUtil.makeRoundBorder(bf, logoStyle, size, Color.BLUE); // 边距为二维码图片的1/10 + + // logo的宽高 + int w = bf.getWidth() > QRCODE_WIDTH * 2 / 10 ? QRCODE_WIDTH * 2 / 10 : bf.getWidth(); + int h = bf.getHeight() > QRCODE_HEIGHT * 2 / 10 ? QRCODE_HEIGHT * 2 / 10 : bf.getHeight(); + + // 插入LOGO + Graphics2D graph = qrCode.createGraphics(); + + int x = (QRCODE_WIDTH - w) / 2; + int y = (QRCODE_HEIGHT - h) / 2; + + graph.drawImage(bf, x, y, w, h, null); + graph.dispose(); + bf.flush(); + } + + + /** + * 根据路径获取图片 + * + * @param path 本地路径 or 网络地址 + * @return 图片 + * @throws IOException + */ + public static BufferedImage getImageByPath(String path) throws IOException { + if (path.startsWith("http")) { // 从网络获取logo +// return ImageIO.read(new URL(path)); + return ImageIO.read(HttpUtil.downFile(path)); + } else if (path.startsWith("/")) { // 绝对地址获取logo + return ImageIO.read(new File(path)); + } else { // 从资源目录下获取logo + return ImageIO.read(ImageUtil.class.getClassLoader().getResourceAsStream(path)); + } + } + + + /** + * fixme 边框的计算需要根据最终生成logo图片的大小来定义,这样才不会出现不同的logo原图,导致边框不一致的问题 + * + * 生成圆角图片 & 圆角边框 + * + * @param image 原图 + * @param logoStyle 圆角的角度 + * @param size 边框的边距 + * @param color 边框的颜色 + * @return 返回带边框的圆角图 + */ + public static BufferedImage makeRoundBorder(BufferedImage image, QrCodeOptions.LogoStyle logoStyle, int size, Color color) { + // 将图片变成圆角 + int cornerRadius = 0; + if (logoStyle == QrCodeOptions.LogoStyle.ROUND) { + cornerRadius = 30; + image = makeRoundedCorner(image, cornerRadius); + } + + int borderSize = size; + int w = image.getWidth() + borderSize; + int h = image.getHeight() + borderSize; + BufferedImage output = new BufferedImage(w, h, + BufferedImage.TYPE_INT_ARGB); + + Graphics2D g2 = output.createGraphics(); + g2.setComposite(AlphaComposite.Src); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON); + g2.setColor(color == null ? Color.WHITE : color); + g2.fill(new RoundRectangle2D.Float(0, 0, w, h, cornerRadius, + cornerRadius)); + + // ... then compositing the image on top, + // using the white shape from above as alpha source + g2.setComposite(AlphaComposite.SrcAtop); + g2.drawImage(image, size, size, null); + g2.dispose(); + + return output; + } + + + /** + * 生成圆角图片 + * + * @param image 原始图片 + * @param cornerRadius 圆角的弧度 + * @return 返回圆角图 + */ + public static BufferedImage makeRoundedCorner(BufferedImage image, + int cornerRadius) { + int w = image.getWidth(); + int h = image.getHeight(); + BufferedImage output = new BufferedImage(w, h, + BufferedImage.TYPE_INT_ARGB); + + Graphics2D g2 = output.createGraphics(); + + // This is what we want, but it only does hard-clipping, i.e. aliasing + // g2.setClip(new RoundRectangle2D ...) + + // so instead fake soft-clipping by first drawing the desired clip shape + // in fully opaque white with antialiasing enabled... + g2.setComposite(AlphaComposite.Src); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON); + g2.setColor(Color.WHITE); + g2.fill(new RoundRectangle2D.Float(0, 0, w, h, cornerRadius, + cornerRadius)); + + // ... then compositing the image on top, + // using the white shape from above as alpha source + g2.setComposite(AlphaComposite.SrcAtop); + g2.drawImage(image, 0, 0, null); + + g2.dispose(); + + return output; + } +} +``` + + +### 5. base64编码工具: `Base64Util` + +```java +public class Base64Util { + public static String encode(ByteArrayOutputStream outputStream) { + return Base64.getEncoder().encodeToString(outputStream.toByteArray()); + } +} +``` + +### 6. 二维码解析工具: `QrCodeDeWrapper` + +```java +public class QrCodeDeWrapper { + + + /** + * 读取二维码中的内容, 并返回 + * + * @param qrcodeImg 二维码图片的地址 + * @return 返回二维码的内容 + * @throws IOException 读取二维码失败 + * @throws FormatException 二维码解析失败 + * @throws ChecksumException + * @throws NotFoundException + */ + public static String decode(String qrcodeImg) throws IOException, FormatException, ChecksumException, NotFoundException { + BufferedImage image = ImageUtil.getImageByPath(qrcodeImg); + return decode(image); + } + + + public static String decode(BufferedImage image) throws FormatException, ChecksumException, NotFoundException { + if (image == null) { + throw new IllegalStateException("can not load qrCode!"); + } + + + LuminanceSource source = new BufferedImageLuminanceSource(image); + BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source)); + QRCodeReader qrCodeReader = new QRCodeReader(); + Result result = qrCodeReader.decode(bitmap); + return result.getText(); + } + +} +``` + +## 3. 填坑 + +### 1. 生成二维码边框过大的问题 + +即便指定了生成二维码图片的边距为0,但是最终生成的二维码图片边框依然可能很大 + +如下图 + +![http://git.oschina.net/uploads/images/2017/0403/120101_e6d40bcb_2334.jpeg](http://git.oschina.net/uploads/images/2017/0403/120101_e6d40bcb_2334.jpeg) + +这个问题上面已经修复,产生的原因和修复过程可以查看 [zxing 二维码大白边一步一步修复指南](https://my.oschina.net/u/566591/blog/872770) + +修复之后如下图 + +![http://git.oschina.net/uploads/images/2017/0403/120811_9014928b_2334.jpeg](http://git.oschina.net/uploads/images/2017/0403/120811_9014928b_2334.jpeg) + + +### 2. 插入logo + +上面虽然实现了插入logo的逻辑,但是生成的边框处有点问题,坑还没填 + +希望是指定边框大小时,不管logo图片有多大,最终的边框一样大小,而上面却有点问题... + + +此外就是生成的logo样式不美观,不能忍啊 + + +## 4. 演示 + +启动spring-boot, 然后开启测试 + +![演示图片](https://raw.githubusercontent.com/liuyueyi/quick-media/master/doc/img/qrcode/qr.gif) + +## 5. 其他 + + +项目源码: [https://github.com/liuyueyi/quick-media](https://github.com/liuyueyi/quick-media) + + +个人博客:[一灰的个人博客](http://zbang.online:8080/articles/2017/07/18/1500369136069.html) + +公众号获取更多: + +![https://static.oschina.net/uploads/img/201707/09205944_0PzS.jpg](https://static.oschina.net/uploads/img/201707/09205944_0PzS.jpg) \ No newline at end of file diff --git "a/docs/\351\207\207\345\235\221/Batik\346\270\262\346\237\223png\345\233\276\347\211\207\345\274\202\345\270\270\347\232\204bug\344\277\256\345\244\215.md" "b/docs/\351\207\207\345\235\221/Batik\346\270\262\346\237\223png\345\233\276\347\211\207\345\274\202\345\270\270\347\232\204bug\344\277\256\345\244\215.md" new file mode 100644 index 00000000..93bf98b7 --- /dev/null +++ "b/docs/\351\207\207\345\235\221/Batik\346\270\262\346\237\223png\345\233\276\347\211\207\345\274\202\345\270\270\347\232\204bug\344\277\256\345\244\215.md" @@ -0,0 +1,240 @@ +# Batik渲染png图片异常的bug修复 + +batik是apache的一个开源项目,可以实现svg的渲染,后端借助它可以比较简单的实现图片渲染,当然和java一贯处理图片不太方便一样,使用起来也有不少坑 + +下面记录一个bug的修复过程 + + + +## 3.1. 问题重现 + +svg文件: + +```xml + + + + + +``` + +依次测试了三个图片,两个png,一个jpg,很不幸第一个png会抛异常 + + +输出的堆栈信息如 + +```sh +The URI "http://image.uc.cn/o/wemedia/s/upload/2017/39c53604fe3587a4876396cf3785b801x200x200x13.png" +on element can't be opened because: +PNG URL is corrupt or unsupported variant + at org.apache.batik.bridge.UserAgentAdapter.getBrokenLinkDocument(UserAgentAdapter.java:448) + at org.apache.batik.bridge.SVGImageElementBridge.createRasterImageNode(SVGImageElementBridge.java:642) + at org.apache.batik.bridge.SVGImageElementBridge.createImageGraphicsNode(SVGImageElementBridge.java:340) + at org.apache.batik.bridge.SVGImageElementBridge.buildImageGraphicsNode(SVGImageElementBridge.java:180) + at org.apache.batik.bridge.SVGImageElementBridge.createGraphicsNode(SVGImageElementBridge.java:122) + at org.apache.batik.bridge.GVTBuilder.buildGraphicsNode(GVTBuilder.java:213) + at org.apache.batik.bridge.GVTBuilder.buildComposite(GVTBuilder.java:171) + at org.apache.batik.bridge.GVTBuilder.build(GVTBuilder.java:82) + at org.apache.batik.transcoder.SVGAbstractTranscoder.transcode(SVGAbstractTranscoder.java:208) + at org.apache.batik.transcoder.image.ImageTranscoder.transcode(ImageTranscoder.java:92) + at org.apache.batik.transcoder.XMLAbstractTranscoder.transcode(XMLAbstractTranscoder.java:142) + at org.apache.batik.transcoder.SVGAbstractTranscoder.transcode(SVGAbstractTranscoder.java:156) + ... +``` + +--- + +## 3.2. 问题定位及分析 + +既然出现了这个问题,那么就要去修复解决了,当然遇到这么鬼畜的问题,最常见的几个步骤: + +1. 其他人遇到过么 (问百度) -- 结果度娘没有给出任何有效的建议,也没有搜到任何有用的信息 +2. 然后问谷歌,靠谱了一点,至少有些相关的主题了,但建设性的意见也没收到 +3. 外援实在找不到,只能debug查问题了 + + +### 1. DEBUG的一路 + +通过上面的堆栈信息,可以想见,debug的几个地方也和明确了,首先定位到下面这一行 + +```sh +at org.apache.batik.bridge.UserAgentAdapter.getBrokenLinkDocument(UserAgentAdapter.java:448) +``` + +为什么这么干?因为首先得确认下这个异常是怎么抛出来的,逆向推,直接看源码,发现直接抛出异常 + +![2A02AB38-25ED-4B71-8CE7-76460623FE08.png](https://s17.mogucdn.com/mlcdn/c45406/180119_67ik6b6l9kb199gjaachbkdce89dk_1274x284.jpg) + +再往上走 + +```sh +at org.apache.batik.bridge.SVGImageElementBridge.createRasterImageNode(SVGImageElementBridge.java:642) +``` + +![D1CECFCF-D940-4A17-87F9-7E12B00517D9.png](https://s17.mogucdn.com/mlcdn/c45406/180119_61el9dg4f32iafif6i57g0hac8e7i_1328x832.jpg) + +所以说因为这个if条件判断成立,导致进入了这个异常逻辑,判断的逻辑也没啥好说的,现在的关键是这个参数对象img是怎么来的 + +```sh +at org.apache.batik.bridge.SVGImageElementBridge.createImageGraphicsNode(SVGImageElementBridge.java:340) +``` + +![IMAGE](https://s17.mogucdn.com/mlcdn/c45406/180119_645jcjh7hae6g6e7k3bakhje593a7_1320x508.jpg) + +然后就稍微清晰一点了,直接将火力放在下面的方法中 + +```java +org.apache.batik.ext.awt.image.spi.ImageTagRegistry#readURL(java.io.InputStream, + org.apache.batik.util.ParsedURL, + org.apache.xmlgraphics.java2d.color.ICCColorSpaceWithIntent, + boolean, + boolean) +``` + +在这个方法内部,也没什么好说的,单步多调几次,就能发现异常的case是怎么来的了,省略掉中间各种单步debug的过程,下面直接进入关键链路 + +### 2. 火力全开,问题定位 + +```java +org.apache.batik.ext.awt.image.codec.imageio.AbstractImageIORegistryEntry +``` + +通过上面的一路之后,发现最终的关键就是上面这个抽象类,顺带也可以看下这个抽象类的几个子类,有JPEGxxx, PNGxxx, TIFFxxx,然后问题来了,都已经有相关实现了,所以png讲道理应该是会支持的才对吧,但和实际的表现太不一样了吧,所以有必要撸一把源码了 + + +```java +public Filter handleStream(InputStream inIS, + ParsedURL origURL, + boolean needRawData) { + final DeferRable dr = new DeferRable(); + final InputStream is = inIS; + final String errCode; + final Object [] errParam; + if (origURL != null) { + errCode = ERR_URL_FORMAT_UNREADABLE; + errParam = new Object[] {getFormatName(), origURL}; + } else { + errCode = ERR_STREAM_FORMAT_UNREADABLE; + errParam = new Object[] {getFormatName()}; + } + + Thread t = new Thread() { + @Override + public void run() { + Filter filt; + try{ + Iterator iter = ImageIO.getImageReadersByMIMEType( + getMimeTypes().get(0).toString()); + if (!iter.hasNext()) { + throw new UnsupportedOperationException( + "No image reader for " + + getFormatName() + " available!"); + } + ImageReader reader = iter.next(); + ImageInputStream imageIn = ImageIO.createImageInputStream(is); + reader.setInput(imageIn, true); + + int imageIndex = 0; + dr.setBounds(new Rectangle2D.Double + (0, 0, + reader.getWidth(imageIndex), + reader.getHeight(imageIndex))); + CachableRed cr; + //Naive approach possibly wasting lots of memory + //and ignoring the gamma correction done by PNGRed :-( + //Matches the code used by the former JPEGRegistryEntry, though. + BufferedImage bi = reader.read(imageIndex); + cr = GraphicsUtil.wrap(bi); + cr = new Any2sRGBRed(cr); + cr = new FormatRed(cr, GraphicsUtil.sRGB_Unpre); + WritableRaster wr = (WritableRaster)cr.getData(); + ColorModel cm = cr.getColorModel(); + BufferedImage image = new BufferedImage + (cm, wr, cm.isAlphaPremultiplied(), null); + cr = GraphicsUtil.wrap(image); + filt = new RedRable(cr); + } catch (IOException ioe) { + // Something bad happened here... + filt = ImageTagRegistry.getBrokenLinkImage + (AbstractImageIORegistryEntry.this, + errCode, errParam); + } catch (ThreadDeath td) { + filt = ImageTagRegistry.getBrokenLinkImage + (AbstractImageIORegistryEntry.this, + errCode, errParam); + dr.setSource(filt); + throw td; + } catch (Throwable t) { + filt = ImageTagRegistry.getBrokenLinkImage + (AbstractImageIORegistryEntry.this, + errCode, errParam); + } + + dr.setSource(filt); + } + }; + t.start(); + return dr; +} +``` + +看上面的实现是一个非常有意思的事情, 开了一个线程做事情,而且直接就返回了,相当于给了别人一个储物箱的钥匙,虽然现在储物箱是空的,但是回头我会填满的 + +言归正传,主要的业务逻辑就在这个线程里了,核心的几行代码就是 + +```java +// 加载图片,转为BufferedImage对象 +BufferedImage bi = reader.read(imageIndex); +cr = GraphicsUtil.wrap(bi); +// 下面实现对图片的ARGB进行修改 +cr = new Any2sRGBRed(cr); +cr = new FormatRed(cr, GraphicsUtil.sRGB_Unpre); +WritableRaster wr = (WritableRaster)cr.getData(); +ColorModel cm = cr.getColorModel(); +BufferedImage image = new BufferedImage + (cm, wr, cm.isAlphaPremultiplied(), null); +cr = GraphicsUtil.wrap(image); +filt = new RedRable(cr); +``` + +debug上面的几行代码,发现问题比较明显了,就是这个图片的转换跪了,至于为啥? java的图片各种蛋疼至极,这里面的逻辑,真心搞不进去,so深挖到此为止 + +--- + +## 3.3. 兼容逻辑 + +问题定位到了,当然就是想办法来修复了,简单来说,需要兼容的就是图片的类型转换上了,直接用原来的可能会抛异常,所以做了一个简单的兼容逻辑 + +```java +if(bi.getType() == BufferedImage.TYPE_BYTE_INDEXED) { + BufferedImage image = new BufferedImage(bi.getWidth(), bi.getHeight(), BufferedImage.TYPE_INT_ARGB); + Graphics2D g2d = image.createGraphics(); + g2d.drawImage(bi, 0, 0, null); + g2d.dispose(); + cr = GraphicsUtil.wrap(image); +} else { + cr = GraphicsUtil.wrap(bi); + cr = new Any2sRGBRed(cr); + cr = new FormatRed(cr, GraphicsUtil.sRGB_Unpre); + WritableRaster wr = (WritableRaster)cr.getData(); + ColorModel cm = cr.getColorModel(); + BufferedImage image = new BufferedImage + (cm, wr, cm.isAlphaPremultiplied(), null); + cr = GraphicsUtil.wrap(image); +} +``` + + +再次验证,ok + +**注意:** + +一个问题来了,上面的兼容是需要修改源码的,我们可以怎么办?有几种解决方法 + +- 猥琐方法一:down下源码,修改版本,然后传到自己的私服,使用自己的vip包 +- 猥琐方法二:把 batik-codec 工程原样拷贝到自己的项目中,就可以随意的使用改了 +- 猥琐方法三:写一个完全相同的类(包路径完全相同),然后构造一个自定义类加载器,加载这个自己的这个兼容版本的,替换原来的(未测试,不确定是否能行) + + +至于我的选择,就是使用了猥琐方法二 diff --git "a/docs/\351\207\207\345\235\221/markdown\350\275\254\345\233\276\347\211\207\344\270\255\346\226\207\344\271\261\347\240\201.md" "b/docs/\351\207\207\345\235\221/markdown\350\275\254\345\233\276\347\211\207\344\270\255\346\226\207\344\271\261\347\240\201.md" new file mode 100644 index 00000000..12c6f3e3 --- /dev/null +++ "b/docs/\351\207\207\345\235\221/markdown\350\275\254\345\233\276\347\211\207\344\270\255\346\226\207\344\271\261\347\240\201.md" @@ -0,0 +1,56 @@ +# markdown转图片中文乱码 +> 在本机(Mac系统)上测试,markdown转图片,妥妥的,没啥问题;可丢在阿里云服务器上就诡异了 + +## 2.1. 问题表征 + +在阿里云上部署的服务,提供markdonw转图片,本来一切都妥妥的,结果发现传中文过来时,中文全部被方框框给替换掉了,即出现中文乱码了!!! + + +关注几点: + +1. 本机测试ok(mac系统) +2. 在windows虚拟机测试也ok +3. linux系统部署失败 +4. 服务接收的中文非乱码,utf8格式 +5. 生成图片的中文签名正常(采用自定义的娃娃体) + + +## 2.2. 问题分析和解决 + +1. 当出现这个问题时,第一感觉就是中文传给服务的时候就乱码了,这个很容易定位否定 + - 常见手段1 : 打日志输出参数 + - 常见手段2 : 远程debug +2. 中文签名正常,markdonw里面的中文乱码 + - 到这里,刚开始也是怀疑编码格式不对导致,因此设置中文的编码格式,打点输出接受参数的实际编码 +3. 上面两个都排除之外,依然没有啥头绪,这个问题就搁置了一段时间,直到再次在一个项目中遇到了这个问题,才怀疑到可能是系统的字体支持问题 + +4. 百度/google上搜索,发现jre默认不支持中文字体,采用awt进行绘图时,中文乱码 + + +尝试修复问题 + +- 下载宋体 : [simsun.ttf](https://github.com/liuyueyi/quick-media/tree/master/common/src/main/resources/font/simsun.ttf) +- 安装到jre的字体目录 + + ``` + // 登录到服务器 + + // 下载字体 + wget https://github.com/liuyueyi/quick-media/tree/master/common/src/main/resources/font/simsun.ttf + + // 安装字体, jdk的目录与实际相关 + cp simsun.ttf /usr/local/jdk/jre/lib/fonts/ + ``` +- 重启应用 +- 再次测试 + + +到此解决问题 + +## 2.3. 小结说明 + +事后想想,这个其实是一个很明显的问题,自定义的中文签名没有问题,但是其他的中文乱码,这里就可以说明不少的问题了 + +其次就是,刚开始遇到时,排除几个问题之后,没啥头绪,就放掉了,没有继续深究这个问题,导致搁置了较长的时间,有点坑 + +最后也是最重要的一点,个人维护的项目,总感觉激情不是特别的足,有鸵鸟心态,这个问题还是在实际的工作项目中,再次遇到后,并没有花多久就定位并解决掉,原因何在?因为工作的这个需求,必须得完成,遇到问题不管怎么样,必须得想办法去fix掉,不然这个工资就没法愉快的领取,所以还是有压力才有动力 \ No newline at end of file diff --git "a/docs/\351\207\207\345\235\221/\345\205\274\345\256\271ImageIO\350\257\273\345\217\226jpeg\345\233\276\347\211\207\345\217\230\347\272\242.md" "b/docs/\351\207\207\345\235\221/\345\205\274\345\256\271ImageIO\350\257\273\345\217\226jpeg\345\233\276\347\211\207\345\217\230\347\272\242.md" new file mode 100644 index 00000000..e2c9a304 --- /dev/null +++ "b/docs/\351\207\207\345\235\221/\345\205\274\345\256\271ImageIO\350\257\273\345\217\226jpeg\345\233\276\347\211\207\345\217\230\347\272\242.md" @@ -0,0 +1,95 @@ +# 兼容ImageIO读取jpeg图片变红 + +> 使用ImageIO.read()方法,加载图片为BufferedImage对象时,对于某些图片,会出现变红的case + + + +## 4.1 问题重现 + +有问题的图片: ![img](http://s17.mogucdn.com/mlcdn/c45406/170418_68lkjddg3bll08h9c9bk0d8ihkffi_800x1200.jpg) + +测试验证代码 + +```java +/** + * 图片读取之后,颜色变红的测试 + */ +@Test +public void testLoadRedImg() throws IOException { + String url = "http://s17.mogucdn.com/mlcdn/c45406/170418_68lkjddg3bll08h9c9bk0d8ihkffi_800x1200.jpg"; + URL u = new URL(url); + BufferedImage bf = ImageIO.read(u); + ImageIO.write + System.out.println("--over--"); + +} +``` + +debug截图如下: + +![FFC31217-6457-46BD-9DB4-3C0632C2AE07.png](https://s3.mogucdn.com/mlcdn/c45406/180122_0j91i74djjka8bj959db144eecaaj_1882x1100.jpg) + + +## 4.2 问题兼容 + +不实用ImageIO来加载图片,改用Toolkit来实现图片读取,然后再将读取到的图片绘制到BufferedImage对象上 + +```java +@Test +public void testLoadRedImg2() throws MalformedURLException { + String url = "http://s17.mogucdn.com/mlcdn/c45406/170418_68lkjddg3bll08h9c9bk0d8ihkffi_800x1200.jpg"; + URL u = new URL(url); + Image img = Toolkit.getDefaultToolkit().getImage(u); + BufferedImage bf = toBufferedImage(img); + System.out.println("eeee"); +} + + +static BufferedImage toBufferedImage(Image image) { + if (image instanceof BufferedImage) { + return (BufferedImage) image; + } + // This code ensures that all the pixels in the image are loaded + image = new ImageIcon(image).getImage(); + BufferedImage bimage = null; + GraphicsEnvironment ge = GraphicsEnvironment + .getLocalGraphicsEnvironment(); + try { + int transparency = Transparency.OPAQUE; + GraphicsDevice gs = ge.getDefaultScreenDevice(); + GraphicsConfiguration gc = gs.getDefaultConfiguration(); + bimage = gc.createCompatibleImage(image.getWidth(null), + image.getHeight(null), transparency); + } catch (HeadlessException e) { + // The system does not have a screen + } + if (bimage == null) { + // Create a buffered image using the default color model + int type = BufferedImage.TYPE_INT_RGB; + bimage = new BufferedImage(image.getWidth(null), + image.getHeight(null), type); + } + // Copy image to buffered image + Graphics g = bimage.createGraphics(); + // Paint the image onto the buffered image + g.drawImage(image, 0, 0, null); + g.dispose(); + return bimage; +} +``` + + +实测验证 + +![4871A1EA-A9FC-46A8-9C9D-954B93978435.png](https://s3.mogucdn.com/mlcdn/c45406/180122_1aih840f4b60c15hdd51kabc0ee5g_2058x1034.jpg) + +为什么会出现这个问题: + +ImageIO.read()方法读取图片时可能存在不正确处理图片ICC信息的问题,ICC为JPEG图片格式中的一种头部信息,导致渲染图片前景色时蒙上一层红色。 + + +## 4.3 其他 + +#### 参考文档 + +- [Java处理某些图片红色问题](http://blog.csdn.net/amorym/article/details/52936470) diff --git "a/docs/\351\207\207\345\235\221/\345\233\276\347\211\207\346\227\213\350\275\254\351\227\256\351\242\230\344\277\256\345\244\215.md" "b/docs/\351\207\207\345\235\221/\345\233\276\347\211\207\346\227\213\350\275\254\351\227\256\351\242\230\344\277\256\345\244\215.md" new file mode 100644 index 00000000..130a19a8 --- /dev/null +++ "b/docs/\351\207\207\345\235\221/\345\233\276\347\211\207\346\227\213\350\275\254\351\227\256\351\242\230\344\277\256\345\244\215.md" @@ -0,0 +1,41 @@ +# 图片旋转问题修复 +> 图片旋转作为一个常见功能,实际使用中用处挺多,但是这次实现却遇到了个小问题,记录一二 + +使用的几个类 + +- `Graphics2d` +- `AffineTransform` +- BufferedImage + + +## 1.1. Graphics2d 方式 + +利用Graphics2d的rotate方法来实现图片旋转,奇怪的是一直不生效,实现代码如下 + + +```java +BufferedImage bufferedImage = ImageUtil.getImageByPath("bg.png"); +Graphics2D g2d = bufferedImage.createGraphics(); +g2d.rotate(Math.toRadians(90), bufferedImage.getWidth() >> 1, bufferedImage.getHeight() >> 1); +g2d.dispose(); +``` + + + +## 1.2. `AffineTransform` 方式 + +```java +BufferedImage bufferedImage = ImageUtil.getImageByPath("bg.png"); + +AffineTransform tx = new AffineTransform(); +tx.rotate(0.5, bufferedImage.getWidth() / 2, bufferedImage.getHeight() / 2); + +AffineTransformOp op = new AffineTransformOp(tx, + AffineTransformOp.TYPE_BILINEAR); +bufferedImage = op.filter(bufferedImage, null); +``` + + +## 1.3 参考 + +- [Rotating a Buffered Image : Image « Advanced Graphics « Java](http://www.java2s.com/Code/Java/Advanced-Graphics/RotatingaBufferedImage.htm) \ No newline at end of file diff --git a/plugins/phantom-plugin/pom.xml b/plugins/phantom-plugin/pom.xml index bba410cf..503787b3 100644 --- a/plugins/phantom-plugin/pom.xml +++ b/plugins/phantom-plugin/pom.xml @@ -27,6 +27,12 @@ 3.12.0 + + org.jsoup + jsoup + 1.11.3 + + com.github.detro ghostdriver diff --git a/plugins/phantom-plugin/src/test/java/com/github/hui/quick/plugin/test/Html2ImgTest.java b/plugins/phantom-plugin/src/test/java/com/github/hui/quick/plugin/test/Html2ImgTest.java index f2e0c68d..8791bd1e 100644 --- a/plugins/phantom-plugin/src/test/java/com/github/hui/quick/plugin/test/Html2ImgTest.java +++ b/plugins/phantom-plugin/src/test/java/com/github/hui/quick/plugin/test/Html2ImgTest.java @@ -1,6 +1,8 @@ package com.github.hui.quick.plugin.test; import com.github.hui.quick.plugin.phantom.Html2ImageByJsWrapper; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; import org.junit.Test; import java.awt.image.BufferedImage;