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閱讀 229,885評論 6 541
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,312評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 177,993評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,667評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,410評論 6 411
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,778評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,775評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,955評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,521評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,266評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,468評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,998評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,696評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,095評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,385評論 1 294
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,193評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,431評論 2 378

推薦閱讀更多精彩內容