Java 竖排长图文生成

背景

前面Java 实现长图文生成 中实现了一个基本的长图文生成工具,但遗留了一些问题

  • 文字中包含英文字符时,分行计算问题
  • 暂不支持竖排文字展示

其中英文字符的计算已经修复,主要是通过FontMetric来计算字符串实际占用绘制的长度,这一块不做多讲,本篇主要集中在竖排文字的支持

设计

有前面的基础,在做竖排文字支持上,本以为是比较简单就能接入的,而实际的实现过程中,颇为坎坷

1. 竖排文字绘制

首先需要支持竖排文字的绘制,使用Graphics2d进行绘制时,暂不支持竖排绘制方式,因此我们需要自己来实现

而设计思路也比较简单,一个字一个字的绘制,x坐标不变,y坐标依次增加

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

举一个例子来看如何进行自动换行

// 列容量 
contain = 100

// FontMetric 相关信息:
fontMetric.ascent = 18;
fontMetric.descent = 4;
fontMetric.height = 22;

// 待绘制的内容为
content = "这是一个待绘制的文本长度,期待自动换行";

首先我们是需要获取内容的总长度,中文还比较好说,都是方块的,可以直接用 fontMetrics.stringWidth(content) 获取内容长度(实际为宽度),然后需要加空格(即descent)

所以计算最终的行数可以如下

// 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坐标

tmpLen = fontMetrics.charWidth(str.charAt(i)) + fontMetrics.getDescent();

其次就是需要判断是否要换行

lastTotal += tmpLen;
if(lastTotal > contain) {
  // 换行
}

3. 从右到左支持

从左到右还比较好说,y坐标一直增加,当绘制的内容超过当前的图片时,直接在扩展后的图片上(0,0)位置进行绘制即可;

而从右到左则需要计算偏移量,如下图

offset

实现

1. 文本自动换行

实现一个公共方法,根据上面的思路用于文本的自动换行

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
/**
 * 垂直绘制时,根据不同的对其方式,计算起始的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坐标,向下偏移一个字

realY = calOffsetY(xxx) + fontMetrics.getAscent();

//...

// 每当绘制完一个文本后,下个文本的Y坐标,需要加上这个文本所占用的高度+间距
realY += fontMetrics.charWidth(tmp.charAt(i)) + g2d.getFontMetrics().getDescent();

3. 换行时,x坐标计算

绘制方式的不同,从左到右与从右到左两种场景下,自动换行后,新行的x坐标的增量计算方式也是不同的

  • 从左到右:int fontWidth = 字体宽度 + 行间距
  • 从右到左:int fontWidth = - (字体宽度 + 行间距)

完整的实现逻辑如下

 /**
 * 垂直文字绘制
 *
 * @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坐标的值即可

此外当图片大于参数指定的高度时,对图片进行按照高度进行缩放处理;当小于高度时,就原图绘制即可

实现逻辑如下

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. 更新内容的宽度
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计算不同罢了

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,
                null);
    }

    int w = GraphicUtil.drawVerticalImage(result, bufferedImage, contentW, options);
    contentW += w + options.getLinePadding();
    return this;
}

5. 输出

上面是绘制的过程,绘制完毕之后,需要输出为图片的,因此对于这个输出需要再适配一把

再前一篇的基础上,输出新增了签名+背景的支持,这里一并说了

  • 计算生成图片的宽高
  • 有签名时,绘制签名背景,在最下方绘制签名文本
  • 背景图片
  • 绘制填充内容
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

@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/05182138_My1E.png

补充一张,竖排文字时,标点符号应该居右(之前完全没意识到),修正的图片样式如下

imgShow

其他

相关博文:《Java 实现长图文生成》

项目地址:https://github.com/liuyueyi/quick-media

个人博客:一灰的个人博客

公众号获取更多:

个人信息
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,602评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,442评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,878评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,306评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,330评论 5 373
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,071评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,382评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,006评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,512评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,965评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,094评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,732评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,283评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,286评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,512评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,536评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,828评论 2 345

推荐阅读更多精彩内容