问题记录
1、代码中使用 ImageMagick 字体加载异常
org.im4java.core.CommandException: /usr/local/Cellar/graphicsmagick/1.3.38_1/bin/gm convert: Unable to read font (/usr/local/share/ghostscript/fonts/n019003l.pfb) [No such file or directory]. 复制代码
原因:使用 Homebrew 安装了 ImageMagick,缺少 ghostscript。
解决方案:
brew install ghostscript 复制代码
如果您使用的是 Ubuntu 或在带有 Ubuntu 的 docker 中删除并重新安装 ghostscript 包将解决您的问题。
apt remove ghostscript apt install ghostscript 复制代码
技术实现
上面展示了三种技术的简单实现,可以直观的看到,Graphics2D 和 Im4Java 能够实现文字和图片水印,但 Im4Java 需要额外安装 GraphicsMagick,不管是 Windows 还是 Mac,又或者是 Linux 上,都可以事先安装 GraphicsMagick,那么我们姑且认为 Im4Java 也满足基本需要。
回头看看我们的需求:文字水印可能会有多行,且字体大小和样式可能存在不同,图片水印可能有多个,这些条件无疑增加了实现难度。
Graphics2D
public class Graphics2DUtil { private static final int[] ICON_LEFT_MARGINS = new int[]{400, 100}; private static final String FONT_FAMILY = "楷体"; private static final String CERTIFICATE_BASE_PATH = "/src/main/resources/static/certificate-blank.png"; private static final String WATERMARK_IMAGE_PATH = "/src/main/resources/static/icon.png"; public static void graphics2DDrawTest(String srcImgPath, String waterImgPath, String outPath, String[] iconNums) { try { BufferedImage targetImg = ImageIO.read(new File(srcImgPath)); int imgWidth = targetImg.getWidth(); int imgHeight = targetImg.getHeight(); BufferedImage bufferedImage = new BufferedImage(imgWidth, imgHeight, BufferedImage.TYPE_INT_BGR); Graphics2D g = bufferedImage.createGraphics(); g.drawImage(targetImg, 0, 0, imgWidth, imgHeight, null); g.setColor(Color.BLACK); // 第一行文本字体大小为120,居中显示 Font userNameFont = new Font(FONT_FAMILY, Font.PLAIN, 120); g.setFont(userNameFont); String userName = "hresh"; int[] userNameSize = getContentSize(userNameFont, userName); int userNameLeftMargin = (imgWidth - userNameSize[0]) / 2; int userNameTopMargin = 400 + userNameSize[1]; g.drawString(userName, userNameLeftMargin, userNameTopMargin); g.dispose(); // 第二行文本的字体不一样,居中显示 g = (Graphics2D) bufferedImage.getGraphics(); Font secondFont = new Font(FONT_FAMILY, Font.PLAIN, 72); g.setFont(secondFont); g.setColor(Color.BLUE); String content = "Hello World"; int[] contentSize = getContentSize(secondFont, content); int contentLeftMargin = (imgWidth - contentSize[0]) / 2; int contentTopMargin = 600 + contentSize[1]; g.drawString(content, contentLeftMargin, contentTopMargin); int imgLeftMargin = ICON_LEFT_MARGINS[iconNums.length - 1]; int imgTopMargin = 1000; BufferedImage image = ImageIO.read(new File(waterImgPath)); int[] imgSize = getImgSize(image); for (int i = 0; i < iconNums.length; i++) { if (i > 0) { imgLeftMargin = imgLeftMargin + imgSize[0] + 10; } BufferedImage icon = ImageIO.read(new File(waterImgPath)); g.drawImage(icon, imgLeftMargin, imgTopMargin, icon.getWidth(), icon.getHeight(), null); } BufferedImage icon = ImageIO.read(new File(waterImgPath)); g.drawImage(icon, 350, 600, icon.getWidth(), icon.getHeight(), null); FileOutputStream outImgStream = new FileOutputStream(outPath); ImageIO.write(bufferedImage, "png", outImgStream); g.dispose(); } catch (IOException e) { e.getStackTrace(); } } /** * 获取文本的长度,字体大小不同,长度也不同 * * @param font * @param content * @return */ public static int[] getContentSize(Font font, String content) { int[] contentSize = new int[2]; FontRenderContext frc = new FontRenderContext(new AffineTransform(), true, true); Rectangle rec = font.getStringBounds(content, frc).getBounds(); contentSize[0] = (int) rec.getWidth(); contentSize[1] = (int) rec.getHeight(); return contentSize; } /** * 获取图片的宽和高 * * @param img * @return */ public static int[] getImgSize(BufferedImage img) { int[] imgSize = new int[2]; imgSize[0] = img.getWidth(null); // 得到源图宽 imgSize[1] = img.getHeight(null); // 得到源图高 return imgSize; } public static void main(String[] args) throws IOException { String projectPath = System.getProperty("user.dir"); String srcImgPath = projectPath + CERTIFICATE_BASE_PATH; String waterImgPath = projectPath + WATERMARK_IMAGE_PATH; String outPath = projectPath + "/src/main/resources/static/out/image_by_graphics2D.png"; // 假设图片水印背景图不一样,1对应1.png,2对应2.png String[] iconNums = new String[]{"1", "2"}; graphics2DDrawTest(srcImgPath, waterImgPath, outPath, iconNums); } } 复制代码
执行效果如下:
从结果上来看,Graphics2D 可以满足我们的需求,如果文本内容过长,需要换行,又该如何做呢?
private static final int TEXT_AREA_WIDTH = 1150; private static final int TEXT_AREA_LEFT_MARGIN = 100; private static final int TEXT_AREA_RIGHT_MARGIN = 100; private static void drawTextWithFontStyleLineFeed(Graphics2D g, String userName, int imgWidth) { int userNameFontSize = 110; Font userNameFont = new Font(FONT_FAMILY, Font.PLAIN, userNameFontSize); int[] userNameSize = getContentSize(userNameFont, userName); if (userNameSize[0] > TEXT_AREA_WIDTH) { userNameFontSize = 80; userNameFont = new Font(FONT_FAMILY, Font.PLAIN, userNameFontSize); } if (Objects.equals(userNameFontSize, 80)) { g.setFont(userNameFont); userNameSize = getContentSize(userNameFont, userName); int userNameWidth = userNameSize[0]; int wordWidth = userNameWidth / userName.length(); int wordNum = TEXT_AREA_WIDTH / wordWidth; String userNameFirst = userName.substring(0, wordNum); String userNameSec = userName.substring(wordNum); int userNameFirstWidth = wordWidth * wordNum; int userNameFirstLeftMargin = (imgWidth - userNameFirstWidth - TEXT_AREA_LEFT_MARGIN - TEXT_AREA_RIGHT_MARGIN) / 2 + TEXT_AREA_LEFT_MARGIN; int userNameFirstTopMargin = 700; g.drawString(userNameFirst, userNameFirstLeftMargin, userNameFirstTopMargin); int userNameSectWidth = wordWidth * (userName.length() - wordNum); int userNameSecLeftMargin = (imgWidth - userNameSectWidth - TEXT_AREA_LEFT_MARGIN - TEXT_AREA_RIGHT_MARGIN) / 2 + TEXT_AREA_LEFT_MARGIN; int userNameSecTopMargin = 700 + userNameSize[1] + 70; g.drawString(userNameSec, userNameSecLeftMargin, userNameSecTopMargin); } else { g.setFont(userNameFont); userNameSize = getContentSize(userNameFont, userName); int userNameLeftMargin = (imgWidth - userNameSize[0] - TEXT_AREA_LEFT_MARGIN - TEXT_AREA_RIGHT_MARGIN) / 2 + TEXT_AREA_LEFT_MARGIN; int userNameTopMargin = 363 * 3 + userNameSize[1] - 10; g.drawString(userName, userNameLeftMargin, userNameTopMargin); g.dispose(); } } 复制代码
用 drawUserNameWithFontStyleLineFeed()方法来替代 graphics2DDrawTest()方法中关于添加文字水印的代码,执行效果如下所示:
至此,关于 Graphics2D 的技术实现细节基本搞定了,也非常符合我们的要求。
Im4Java
首先是文字水印字体大小不同的问题,我们尝试修改代码来解决该问题。
public static void addTextWatermark(String srcImagePath, String destImagePath, String content) throws Exception { GMOperation op = new GMOperation(); op.font("/System/Library/Fonts/Supplemental/Songti.ttc"); // 文字方位-居中 op.gravity("center"); op.pointsize(120).fill("#BCBFC8").draw("text 0,0 '" + content + "'").quality(90.0); op.gravity("center"); op.pointsize(80).fill("#BCBFC8").draw("text 0,150 '" + content + "'").quality(90.0); // 原图 op.addImage(); // 目标 op.addImage(); ImageCommand cmd = getImageCommand(CommandType.textWaterMark); cmd.run(op, srcImagePath, destImagePath); } 复制代码
执行效果如下:
接着是文本换行问题,实现起来还算简单。
private static final String FONT_FAMILY_PATH = "/src/main/resources/static/SourceHanSerif-Light.ttc"; public static void addTextWatermark(String srcImagePath, String destImagePath, String content) throws Exception { GMOperation op = new GMOperation(); String projectPath = System.getProperty("user.dir"); op.font(projectPath + FONT_FAMILY_PATH); String text = "这是一个专注于IT技术学习交流的个人技术博客网站"; BufferedImage targetImg = ImageIO.read(new File(srcImagePath)); int imgWidth = targetImg.getWidth(); drawTextWithFontStyleLineFeed(op, text, imgWidth); // 原图 op.addImage(); // 目标 op.addImage(); ImageCommand cmd = getImageCommand(CommandType.textWaterMark); cmd.run(op, srcImagePath, destImagePath); } private static final int TEXT_AREA_WIDTH = 1150; private static final int TEXT_AREA_LEFT_MARGIN = 100; private static final int TEXT_AREA_RIGHT_MARGIN = 100; private static void drawTextWithFontStyleLineFeed(GMOperation op, String text, int imgWidth) { float textFontSize = 110f; int[] textSize = getContentSize(text, textFontSize); if (textSize[0] > TEXT_AREA_WIDTH) { textFontSize = 80f; textSize = getContentSize(text, textFontSize); } if (Objects.equals(textFontSize, 80f)) { int textWidth = textSize[0]; int wordWidth = textWidth / text.length(); int wordNum = TEXT_AREA_WIDTH / wordWidth; String textFirst = text.substring(0, wordNum); String textSec = text.substring(wordNum); int textFirstWidth = wordWidth * wordNum; int textFirstLeftMargin = (imgWidth - textFirstWidth - TEXT_AREA_LEFT_MARGIN - TEXT_AREA_RIGHT_MARGIN) / 2 + TEXT_AREA_LEFT_MARGIN; int textFirstTopMargin = 50; op.gravity("west"); op.pointsize(80).fill("#000000") .draw("text " + textFirstLeftMargin + "," + textFirstTopMargin + " '" + textFirst + "'") .quality(90.0); int textSectWidth = wordWidth * (text.length() - wordNum); int textSecLeftMargin = (imgWidth - textSectWidth - TEXT_AREA_LEFT_MARGIN - TEXT_AREA_RIGHT_MARGIN) / 2 + TEXT_AREA_LEFT_MARGIN; int textSecTopMargin = 50 + textSize[1] + 10; op.gravity("west"); op.pointsize(80).fill("#000000") .draw("text " + textSecLeftMargin + "," + textSecTopMargin + " '" + textSec + "'") .quality(90.0); } } private static Font getFont(float fontSize) { try { InputStream resourceAsStream = new ClassPathResource("static/SourceHanSerif-Light.ttc").getInputStream(); Font font = Font.createFont(Font.TRUETYPE_FONT, resourceAsStream); return font.deriveFont(fontSize); } catch (Exception e) { log.error(e.getMessage()); } return new Font(FONT_FAMILY, Font.PLAIN, 120); } public static int[] getContentSize(String content, float fontSize) { Font font = getFont(fontSize); int[] contentSize = new int[2]; FontRenderContext frc = new FontRenderContext(new AffineTransform(), true, true); Rectangle rec = font.getStringBounds(content, frc).getBounds(); contentSize[0] = (int) rec.getWidth(); contentSize[1] = (int) rec.getHeight(); return contentSize; } 复制代码
执行效果如下:
还有多个图片水印的问题,勉强可以实现,但想要实现动态化比较繁琐。
public static void addImgWatermark2(String srcImagePath, String destImagePath, String waterImgPath) throws Exception { String projectPath = System.getProperty("user.dir"); // 原始图片信息 BufferedImage targetImg = ImageIO.read(new File(srcImagePath)); // 水印图片 BufferedImage watermarkImage = ImageIO.read(new File(waterImgPath)); int w = targetImg.getWidth(); int h = targetImg.getHeight(); int watermarkImageWidth = watermarkImage.getWidth(null); int watermarkImageHeight = watermarkImage.getHeight(null); IMOperation op2 = new IMOperation(); // 水印图片位置 op2.geometry(watermarkImageWidth, watermarkImageHeight, w - watermarkImageWidth - 600, h - watermarkImageHeight - 300); // 水印透明度 op2.dissolve(90); // 水印 op2.addImage(waterImgPath); // 原图 op2.addImage(srcImagePath); // 目标 String outPath = projectPath + "/src/main/resources/static/out/img_water_image22_by_im4.png"; op2.addImage(outPath); ImageCommand cmd = getImageCommand(CommandType.imageWaterMark); cmd.run(op2); IMOperation op = new IMOperation(); // 水印图片位置 op.geometry(watermarkImageWidth, watermarkImageHeight, w - watermarkImageWidth, h - watermarkImageHeight - 300); // 水印透明度 op.dissolve(90); // 水印 op.addImage(waterImgPath); // 原图 op.addImage(outPath); // 目标 op.addImage(destImagePath); cmd.run(op); } 复制代码
执行效果如下:
最后同时生成文字水印和图片水印
public static void addImgWatermark(String srcImagePath, String destImagePath, String waterImgPath) throws Exception { IMOperation op = new IMOperation(); String projectPath = System.getProperty("user.dir"); op.font(projectPath + FONT_FAMILY_PATH); op.gravity("center"); String content = "中国"; op.pointsize(120).fill("#BCBFC8").draw("text 0,0 '" + content + "'").quality(90.0); // 原图 op.addImage(); // 目标 op.addImage(); ImageCommand cmd = getImageCommand(CommandType.textWaterMark); cmd.run(op, srcImagePath, destImagePath); // 原始图片信息 BufferedImage targetImg = ImageIO.read(new File(destImagePath)); // 水印图片 BufferedImage watermarkImage = ImageIO.read(new File(waterImgPath)); int w = targetImg.getWidth(); int h = targetImg.getHeight(); int watermarkImageWidth = watermarkImage.getWidth(null); int watermarkImageHeight = watermarkImage.getHeight(null); IMOperation op2 = new IMOperation(); // 水印图片位置 op2.geometry(watermarkImageWidth, watermarkImageHeight, w - watermarkImageWidth - 300, h - watermarkImageHeight - 100); // 水印透明度 op2.dissolve(90); // 水印 op2.addImage(waterImgPath); // 原图 op2.addImage(destImagePath); // 目标 String outPath = projectPath + "/src/main/resources/static/out/img_water_image22_by_im4.png"; op2.addImage(outPath); ImageCommand cmd2 = getImageCommand(CommandType.imageWaterMark); cmd2.run(op2); } 复制代码
执行效果如下:
勉强能实现功能,但代码看起来太费劲了。
小结
GraphicsMagick 与 Im4Java 结合使用功能非常不错,除了添加文字水印和图片水印外,还可以旋转图片,压缩、裁剪等等操作,感兴趣的朋友可以阅读这篇文章。
关于图片处理也可以考虑 Thumbnailator,同样具备图片压缩、裁剪等功能。
Graphics2D 针对图片也可以实现压缩、裁剪等功能,推荐阅读这篇文章。
综上所述,Graphics2D 可以在一张背景图上同时添加文字和图片水印,以及文字可以按需求设置字体大小和样式,还可以同时添加多个图片水印。我们最终选择 Graphics2D 作为实现文字水印和图片水印的技术方案。
扩展
上面敲定了技术方案,但在实际应用中,还会遇到一些意想不到的问题,这里简单总结一些我经历过的问题,希望对大家有所帮助。
Docker发布镜像报错
比如说使用 Jenkins 发布镜像出现如下错误信息:
NoClassDefFoundError: Could not initialize class sun.awt.X11FontManager 复制代码
问题原因:这种一般是出现在 docker 部署,且使用了精简版的 linux 基础镜像, 精简到把字体都阉割掉了,可以查看项目中的 Dockerfile 文件,比如说:
FROM openjdk:8u265-jdk-slim 复制代码
使用的是 openjdk 的 docker 'slim images',则该镜像就不包含包 'fontconfig' 和 'libfreetype6'。
如果你的项目有字体相关操作,比如导出 excel,就会报上述异常。
解决办法:
1、换个东西全一点的镜像;
2、在构建镜像时安装字体,dockerfile增加命令:
RUN apt-get update; apt-get install -y fontconfig libfreetype6 复制代码
国际化问题
我们想要在图片上添加文字水印,就要考虑乱码的原因,不止英文,还有中日韩,以及欧洲的各种语言。
回头看看我们写的代码,它支持中日韩,当然也支持英文。
private static final String FONT_FAMILY = "楷体"; Font userNameFont = new Font(FONT_FAMILY, Font.PLAIN, userNameFontSize); 复制代码
示例如下:
我们既然选择使用 Graphics2D 来渲染文本,就要了解 Java 应用程序有哪些方式来选择字体。
- 使用逻辑字体名称:Java 2 平台定义了每个实现必须支持的五个逻辑字体名称:Serif、SansSerif、Monospaced、Dialog 和 DialogInput。这些逻辑字体名称以与实现相关的方式映射到物理字体。通常,一个逻辑字体名称映射到几种物理字体,以涵盖大范围的字符。
- 使用物理字体名称:Java 2 平台提供的 API 允许应用程序确定哪些字体可用于给定的运行时以及这些字体可以处理哪些字符,并使用它们的真实名称(例如,“Times Roman”或“赫尔维蒂卡”)。应用程序可以让用户选择字体或以编程方式确定要使用的字体。
- 使用 Lucida 字体:Sun 的 Java 2 运行时环境包含这个物理字体系列,它也被许可用于 Java 2 平台的其他实现。这些字体是物理字体,但不依赖于主机操作系统。
- 使用捆绑的物理字体:应用程序可以捆绑 TrueType 字体并使用该
Font.createFont
方法实例化它们。
针对四种方式,根据个人理解列举相关示例代码:
1、使用逻辑字体名称
Font userNameFont = new Font("Serif", Font.PLAIN, 120); // Font对象 java.awt.Font[family=Serif,name=Serif,style=plain,size=120] 复制代码
2、使用物理字体名称
Font userNameFont = new Font("楷体", Font.PLAIN, 120); // Font对象 java.awt.Font[family=Dialog,name=楷体,style=plain,size=120] 复制代码
3、使用 Lucida 字体
查看本机 JDK 安装目录,在 /jre/lib/fonts 目录下有 Lucida 字体。
4、使用捆绑的物理字体
private static Font getFont(float fontSize) { try { InputStream resourceAsStream = new ClassPathResource("static/SourceHanSerif-Light.ttc") .getInputStream(); Font font = Font.createFont(Font.TRUETYPE_FONT, resourceAsStream); return font.deriveFont(fontSize); } catch (Exception e) { log.error(e.getMessage()); } return new Font(FONT_FAMILY, Font.PLAIN, 120); } 复制代码
上述四种方法各有优势,区别如下所示:
- 使用逻辑字体名称:
- 优点:保证这些字体名称可以在任何地方使用,并且它们至少可以使用主机操作系统本地化的语言(通常是更大范围的语言)进行文本渲染。
- 缺点:用于呈现文本的物理字体因不同的实现、主机操作系统和语言环境而异,因此应用程序无法在任何地方实现相同的外观。此外,映射机制有时会限制可以呈现的字符范围。后者曾经是 1.5 之前的 J2RE 版本的一个大问题:例如,日文文本只能在日文本地化的主机操作系统上呈现,即使安装了日文字体,也不能在其他本地化系统上呈现。对于使用 2D 字体渲染的应用程序,此问题在 J2RE 1.5.0 版中更为罕见,因为映射机制现在通常可以识别并使用所有支持的书写系统的字体(如果已安装)。
- 使用物理字体名称:
- 优点:这种方法让应用程序可以充分利用所有可用字体,以实现不同的文本外观和最大的语言覆盖率。
- 缺点:这种方法很难编程。
- 使用 Lucida 字体:
- 优点:使用这些字体的应用程序可以在这些字体可用的地方实现相同的外观。此外,这些字体涵盖多种语言(尤其是欧洲和中东),因此您可以为支持的语言创建完全多语言的应用程序。
- 缺点:这些字体可能并非在所有 Java 2 运行时环境中都可用。此外,它们目前不涵盖完整的 Unicode 字符集;特别是不支持中文、日文和韩文。
- 使用捆绑的物理字体:
- 优点:使用这些字体的应用程序可以在任何地方实现相同的外观,并且可以完全控制它们支持的语言。
- 缺点:捆绑的字体可能会很大,特别是如果它们支持中文、日文和韩文。需要解决许可问题。
上面废话太多,咱简单总结一下:
使用逻辑字体名称,依赖于本机上安装的字体,比如说 Windows、Mac、Linux 三种不同的系统一旦安装的物理字体不一致,那么还可能存在乱码问题。使用物理字体名称本质上和逻辑字体面临一样的问题。Lucida 字体都说了不支持中日韩语言,所以不做考虑。而使用捆绑的物理字体使用起来比较费劲,针对多种语言,可能需要捆绑的字体各不相同。
实际应用时该如何选择,这个要根据实际需要进行分析,如果不需要考虑国际化问题,那就简单了。恰好自己遇到了国际化问题,本人的处理方法是结合使用物理字体名称和捆绑物理字体,使用物理字体名称来应对大多数语言,针对乱码的语言可以特殊处理,即将项目中字体资源加载到当前运行环境中。
下面举例演示如何处理中日韩三种语言乱码的情况,首先得找到一个应对中日韩乱码的字体,这里使用的是思源宋体——SourceHanSerif-Light.ttc,接着需要根据文本内容来判断对应什么语言,如果是中日韩语言,才需要使用思源宋体。
private boolean isContainChinese(String text) { Pattern p = Pattern .compile("[\u4E00-\u9FA5|!|,|。|(|)|《|》|“|”|?|:|;|【|】]"); Matcher m = p.matcher(text); return m.find(); } private boolean isJapanese(String text) { try { return text.getBytes("shift-jis").length >= (2 * text.length()); } catch (UnsupportedEncodingException e) { return false; } } private boolean checkKoreaChar(String text) { char[] chars = text.toCharArray(); for (char ch : chars) { if ((ch > 0x3130 && ch < 0x318F) || (ch >= 0xAC00 && ch <= 0xD7A3)) { return true; } } return false; } // 在resource目录下上传字体资源文件 private Font getFont(String text, float fontSize) { try { String fontPath = ""; if (isJapanese(text) || isContainChinese(text) || checkKoreaChar(text)) { fontPath = "/static/fonts/SourceHanSerif-Light.ttc"; }else{ return new Font("CeraPro", Font.PLAIN, fontSize); } // 此种写法适用于SpringBoot框架 InputStream resourceAsStream = this.getClass().getResourceAsStream(fontPath); // 通用写法如下: //InputStream resourceAsStream = new ClassPathResource("static/SourceHanSerif-Light.ttc") // .getInputStream(); Font font = Font.createFont(Font.TRUETYPE_FONT, resourceAsStream); return font.deriveFont(fontSize); } catch (Exception e) { log.error(e.getMessage()); } return new Font("CeraPro", Font.PLAIN, fontSize); } 复制代码
总结
工作上遇到技术问题时,最好先做好技术调研工作,尽量全面,不要找到一种就认为完事大吉了,我们还要考虑业务需求,将各种技术难点列举出来,看看该方案是否都能解决。
回头看看自己解决文字水印和图片水印的过程,当时自己只找到前两种技术方案,没有注意到 Im4Java,所以直接就选用了 Graphics2D 作为技术选型。恰好它能满足添加文字水印中遇到的字体样式问题,以及文本换行问题,所以一路还算顺利,没有返工操作。但这只是侥幸,如果当时我还注意到了 Im4Java,那么我会一开始去尝试 Im4Java。