文章目录
需求
由于图片和文字交流是相互独立的,故仅保留文字交互信息,然后根据文字中心词,匹配图床上的相应url,进行游览画卷构建
- 数据结构:前端传递给后端的是一个对象数组,每个对象包含:
description
:文物/展品的文字描述(如"陶瓷"、“青铜器”)imageUrl
:与该描述对应的默认图片URL(如陶瓷描述对应陶瓷图片URL)
- 后端处理:
- 接收包含
description
和imageUrl
的对象数组 - 对每个对象:
- 获取
imageUrl
对应的图片 - 将图片和描述组合显示在画卷的同一个面板中(图片上方/下方显示对应文字)
- 获取
- 接收包含
- 展示效果:最终生成的画卷中,每个文物/展品都是一个图文结合的面板,而不是图片和文字分离显示
修改细节
前端
generateScroll()
async generateScroll() {
try {
// 禁用按钮防止重复点击
this.generating = true;
uni.showLoading({ title: '生成中...', mask: true });
// 构建记录数据 - 只处理文字类型
const records = this.interactionRecords
.filter(record => record.type === 'text') // 只保留文字类型记录
.map(record => ({
type: 'text', // 强制设置为text类型
content: record.content, // 文字内容
imageUrl: this.getDefaultImageForText(record.content) // 根据内容匹配默认图片
}));
console.log('发送给后端的记录数据:', JSON.stringify(records, null, 2));
// 调用后端接口
const res = await post('/api/scroll/generate', records);
if (!res) {
throw new Error('未获取到有效响应');
}
// 预览生成的画卷
uni.previewImage({
current: res,
urls: [res],
success: () => {
// 记录生成历史
this.interactionRecords.push({
type: 'scroll',
content: '生成游览画卷',
imageUrl: res,
timestamp: new Date().getTime(),
});
},
fail: (err) => {
throw new Error('图片预览失败: ' + (err.errMsg || '未知错误'));
},
});
} catch (error) {
console.error('生成失败:', error);
uni.showToast({
title: '生成失败: ' + (error.message || '请稍后重试'),
icon: 'none',
duration: 2000,
});
} finally {
this.generating = false;
uni.hideLoading();
}
},
// 根据文本内容返回匹配的默认图片URL
getDefaultImageForText(text) {
const defaultImages = {
'佛像': 'https://i.ibb.co/fGH1bnHs/OIP-C-1.webp',
'佛教': 'https://i.ibb.co/fGH1bnHs/OIP-C-1.webp',
'陶瓷': 'https://i.ibb.co/R4kywTQs/OIP-C.webp',
'青铜器': 'https://i.ibb.co/fV1xCcYd/25bb-hyrtarw2279586.jpg',
'书画': 'https://example.com/default-painting.jpg', // 替换为实际URL
'文物': 'https://example.com/default-artifact.jpg' // 替换为实际URL
};
// 查找匹配的关键词
const matchedKey = Object.keys(defaultImages).find(key =>
text.includes(key)
);
// 返回匹配的图片URL或默认URL
return matchedKey ? defaultImages[matchedKey] : 'https://example.com/default-museum.jpg';
}
主要修改点说明:
- 过滤非文字类型记录:
- 使用
filter(record => record.type === 'text')
只保留文字类型的交互记录
- 使用
- 统一数据结构:
- 所有记录都设置为
type: 'text'
content
字段包含原始文字内容imageUrl
字段根据文字内容自动匹配默认图片
- 所有记录都设置为
- 改进图片匹配逻辑:
- 使用对象映射方式匹配关键词和图片URL
- 支持多个关键词匹配同一图片(如"佛像"和"佛教")
- 提供默认图片URL作为后备
- 增强日志输出:
- 在发送请求前打印完整的数据结构,便于调试
- 错误处理:
- 保留原有的错误处理逻辑,确保用户体验
前端传递格式
[
{
"type": "text",
"content": "这是第一段文字",
"imageUrl": "https://example.com/background1.jpg"
},
{
"type": "text",
"content": "这是第二段文字",
"imageUrl": "https://example.com/background2.jpg"
}
]
后端
ArtifactItem 类:
- 当前设计同时支持图片和文字类型,但如果只接受文字类型,可以简化这个类
- 可以移除
type
字段和imageUrl
字段,因为不再需要区分类型
public class ArtifactItem {
private String content; // 只需要保留文字内容
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
}
ScrollServiceImpl 类:
generate()
方法中的处理逻辑可以简化,因为不再需要处理图片类型- 移除图片下载相关代码(因为现在传递的是图片url,而不是图片格式)
- 背景生成也需要调整
@Override
public String generate(List<ArtifactItem> records) throws Exception {
List<InfoPanel> panels = new ArrayList<>();
// 1. 生成背景(可选,如果仍需动态背景)
BufferedImage bg = generateNewBackground();
BufferedImage frame = loadResourceImage(FRAME_IMAGE_PATH);
// 2. 直接使用前端传递的 imageUrl
for (ArtifactItem record : records) {
if ("text".equals(record.getType())) {
panels.add(new InfoPanel(record.getImageUrl(), record.getContent()));
}
}
// 3. 修改 ScrollHorizontalRollComposer.compose() 方法
// 现在它需要处理 URL 而不是 BufferedImage
BufferedImage content = ScrollHorizontalRollComposer.compose(bg, panels);
BufferedImage finalRoll = ScrollFramer.embed(content, frame);
// 其余代码保持不变...
return uploadToImageHost(finalRoll);
}
修改 InfoPanel
结构
- 从
BufferedImage image
改为String imageUrl
。
package com.museum.pojo;
/** 拼画卷时用的“小面板”包装类 */
public class InfoPanel {
private String imageUrl; // 改为存储图片URL
private String text;
public InfoPanel(String imageUrl, String text) {
this.imageUrl = imageUrl;
this.text = text;
}
public String getImageUrl() { return imageUrl; }
public String getText() { return text; }
}
重构 ScrollHorizontalRollComposer
- 动态加载图片(
URLImageLoader.load()
)。 - 添加图片加载失败的降级处理(占位图)。
package com.museum.utils;
import com.museum.pojo.InfoPanel;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.geom.CubicCurve2D;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
public class ScrollHorizontalRollComposer {
// 配置参数(保持不变)
private static final int PANEL_WIDTH = 560;
private static final int PANEL_HEIGHT = 400;
private static final int PANEL_VGAP = 50;
private static final int TOP_PADDING = 30;
private static final int BOTTOM_PADDING = 30;
private static final int CARD_MARGIN = 30;
private static final int CARD_ROUND = 25;
private static final int CARD_ALPHA = 190;
private static final int ZIGZAG_OFFSET = 40;
private static final int TEXT_PADDING = 40;
private static final int FONT_SIZE = 22;
private static final int IMAGE_SIZE = 180;
// HTTP客户端(用于动态加载图片)
private static final OkHttpClient httpClient = new OkHttpClient();
public static BufferedImage compose(BufferedImage bg, List<InfoPanel> panels) {
int panelCount = panels.size();
int totalHeight = TOP_PADDING + BOTTOM_PADDING + panelCount * PANEL_HEIGHT + (panelCount - 1) * PANEL_VGAP;
BufferedImage scroll = new BufferedImage(PANEL_WIDTH, totalHeight, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = scroll.createGraphics();
// 1. 绘制背景(平铺)
for (int y = 0; y < totalHeight; y += bg.getHeight()) {
g.drawImage(bg, 0, y, PANEL_WIDTH, bg.getHeight(), null);
}
// 2. 设置字体和抗锯齿
g.setFont(new Font("Serif", Font.PLAIN, FONT_SIZE));
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
FontMetrics fm = g.getFontMetrics();
int lineHeight = fm.getHeight();
// 3. 绘制每个面板
int cursorY = TOP_PADDING;
List<Point> centers = new ArrayList<>();
for (int i = 0; i < panelCount; i++) {
InfoPanel panel = panels.get(i);
String[] txtLines = panel.getText().split("(?<=\\。)");
// 3.1 计算面板位置(Z字型布局)
int cardWidth = PANEL_WIDTH - 2 * CARD_MARGIN;
int offsetX = (i % 2 == 0) ? ZIGZAG_OFFSET : -ZIGZAG_OFFSET;
int cardX = (PANEL_WIDTH - cardWidth) / 2 + offsetX;
// 3.2 绘制阴影和卡片背景
g.setColor(new Color(0, 0, 0, 28));
g.fillRoundRect(cardX + 5, cursorY + 5, cardWidth, PANEL_HEIGHT, CARD_ROUND, CARD_ROUND);
g.setColor(new Color(255, 255, 255, CARD_ALPHA));
g.fillRoundRect(cardX, cursorY, cardWidth, PANEL_HEIGHT, CARD_ROUND, CARD_ROUND);
// 3.3 动态加载并绘制图片(关键修改点)
try {
BufferedImage img = loadImageFromUrl(panel.getImageUrl());
int imgX = cardX + (cardWidth - IMAGE_SIZE) / 2;
int imgY = cursorY + 30;
g.drawImage(img, imgX, imgY, IMAGE_SIZE, IMAGE_SIZE, null);
} catch (IOException e) {
// 图片加载失败时绘制占位符
g.setColor(Color.LIGHT_GRAY);
g.fillRect(cardX + (cardWidth - IMAGE_SIZE)/2, cursorY + 30, IMAGE_SIZE, IMAGE_SIZE);
g.setColor(Color.RED);
g.drawString("图片加载失败", cardX + 20, cursorY + 60);
}
// 3.4 绘制文字
g.setColor(Color.BLACK);
int textX = cardX + TEXT_PADDING;
int textY = cursorY + 30 + IMAGE_SIZE + 30;
int textMaxWidth = cardWidth - 2 * TEXT_PADDING;
drawWrappedText(g, txtLines, textX, textY, textMaxWidth, lineHeight);
// 记录面板中心点(用于后续绘制连接线)
centers.add(new Point(cardX + cardWidth/2, cursorY + PANEL_HEIGHT/2));
cursorY += PANEL_HEIGHT + PANEL_VGAP;
}
// 4. 绘制面板间的连接线(保持不变)
drawConnectingLines(g, centers);
g.dispose();
return scroll;
}
// 新增方法:从URL加载图片
private static BufferedImage loadImageFromUrl(String imageUrl) throws IOException {
Request request = new Request.Builder().url(imageUrl).build();
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful() || response.body() == null) {
throw new IOException("HTTP " + response.code());
}
return ImageIO.read(response.body().byteStream());
}
}
// 绘制连接线(保持不变)
private static void drawConnectingLines(Graphics2D g, List<Point> centers) {
g.setColor(new Color(90, 90, 90, 180));
float[] dash = {10, 5};
g.setStroke(new BasicStroke(3, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND, 10f, dash, 0));
for (int i = 0; i < centers.size() - 1; i++) {
Point p1 = centers.get(i);
Point p2 = centers.get(i + 1);
int ctrlY = (p1.y + p2.y)/2 + 60 * ((i%2 == 0) ? 1 : -1);
CubicCurve2D curve = new CubicCurve2D.Float(
p1.x, p1.y, p1.x, ctrlY, p2.x, ctrlY, p2.x, p2.y
);
g.draw(curve);
}
}
// 文字换行处理(优化版)
private static void drawWrappedText(Graphics2D g, String[] lines, int x, int y, int maxWidth, int lineHeight) {
FontMetrics fm = g.getFontMetrics();
for (String line : lines) {
if (fm.stringWidth(line) <= maxWidth) {
g.drawString(line, x, y);
y += lineHeight;
} else {
// 处理长文本换行
StringBuilder currentLine = new StringBuilder();
for (char c : line.toCharArray()) {
if (fm.stringWidth(currentLine.toString() + c) > maxWidth) {
g.drawString(currentLine.toString(), x, y);
y += lineHeight;
currentLine.setLength(0);
}
currentLine.append(c);
}
if (currentLine.length() > 0) {
g.drawString(currentLine.toString(), x, y);
y += lineHeight;
}
}
}
}
}
修改后的 ScrollHorizontalRollComposer
InfoPanel
改为存储图片 URL 而非 BufferedImage
,需要重构 ScrollHorizontalRollComposer
类
package com.museum.utils;
import com.museum.pojo.InfoPanel;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.geom.CubicCurve2D;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
public class ScrollHorizontalRollComposer {
// 配置参数(保持不变)
private static final int PANEL_WIDTH = 560;
private static final int PANEL_HEIGHT = 400;
private static final int PANEL_VGAP = 50;
private static final int TOP_PADDING = 30;
private static final int BOTTOM_PADDING = 30;
private static final int CARD_MARGIN = 30;
private static final int CARD_ROUND = 25;
private static final int CARD_ALPHA = 190;
private static final int ZIGZAG_OFFSET = 40;
private static final int TEXT_PADDING = 40;
private static final int FONT_SIZE = 22;
private static final int IMAGE_SIZE = 180;
// HTTP客户端(用于动态加载图片)
private static final OkHttpClient httpClient = new OkHttpClient();
public static BufferedImage compose(BufferedImage bg, List<InfoPanel> panels) {
int panelCount = panels.size();
int totalHeight = TOP_PADDING + BOTTOM_PADDING + panelCount * PANEL_HEIGHT + (panelCount - 1) * PANEL_VGAP;
BufferedImage scroll = new BufferedImage(PANEL_WIDTH, totalHeight, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = scroll.createGraphics();
// 1. 绘制背景(平铺)
for (int y = 0; y < totalHeight; y += bg.getHeight()) {
g.drawImage(bg, 0, y, PANEL_WIDTH, bg.getHeight(), null);
}
// 2. 设置字体和抗锯齿
g.setFont(new Font("Serif", Font.PLAIN, FONT_SIZE));
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
FontMetrics fm = g.getFontMetrics();
int lineHeight = fm.getHeight();
// 3. 绘制每个面板
int cursorY = TOP_PADDING;
List<Point> centers = new ArrayList<>();
for (int i = 0; i < panelCount; i++) {
InfoPanel panel = panels.get(i);
String[] txtLines = panel.getText().split("(?<=\\。)");
// 3.1 计算面板位置(Z字型布局)
int cardWidth = PANEL_WIDTH - 2 * CARD_MARGIN;
int offsetX = (i % 2 == 0) ? ZIGZAG_OFFSET : -ZIGZAG_OFFSET;
int cardX = (PANEL_WIDTH - cardWidth) / 2 + offsetX;
// 3.2 绘制阴影和卡片背景
g.setColor(new Color(0, 0, 0, 28));
g.fillRoundRect(cardX + 5, cursorY + 5, cardWidth, PANEL_HEIGHT, CARD_ROUND, CARD_ROUND);
g.setColor(new Color(255, 255, 255, CARD_ALPHA));
g.fillRoundRect(cardX, cursorY, cardWidth, PANEL_HEIGHT, CARD_ROUND, CARD_ROUND);
// 3.3 动态加载并绘制图片(关键修改点)
try {
BufferedImage img = loadImageFromUrl(panel.getImageUrl());
int imgX = cardX + (cardWidth - IMAGE_SIZE) / 2;
int imgY = cursorY + 30;
g.drawImage(img, imgX, imgY, IMAGE_SIZE, IMAGE_SIZE, null);
} catch (IOException e) {
// 图片加载失败时绘制占位符
g.setColor(Color.LIGHT_GRAY);
g.fillRect(cardX + (cardWidth - IMAGE_SIZE)/2, cursorY + 30, IMAGE_SIZE, IMAGE_SIZE);
g.setColor(Color.RED);
g.drawString("图片加载失败", cardX + 20, cursorY + 60);
}
// 3.4 绘制文字
g.setColor(Color.BLACK);
int textX = cardX + TEXT_PADDING;
int textY = cursorY + 30 + IMAGE_SIZE + 30;
int textMaxWidth = cardWidth - 2 * TEXT_PADDING;
drawWrappedText(g, txtLines, textX, textY, textMaxWidth, lineHeight);
// 记录面板中心点(用于后续绘制连接线)
centers.add(new Point(cardX + cardWidth/2, cursorY + PANEL_HEIGHT/2));
cursorY += PANEL_HEIGHT + PANEL_VGAP;
}
// 4. 绘制面板间的连接线(保持不变)
drawConnectingLines(g, centers);
g.dispose();
return scroll;
}
// 新增方法:从URL加载图片
private static BufferedImage loadImageFromUrl(String imageUrl) throws IOException {
Request request = new Request.Builder().url(imageUrl).build();
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful() || response.body() == null) {
throw new IOException("HTTP " + response.code());
}
return ImageIO.read(response.body().byteStream());
}
}
// 绘制连接线(保持不变)
private static void drawConnectingLines(Graphics2D g, List<Point> centers) {
g.setColor(new Color(90, 90, 90, 180));
float[] dash = {10, 5};
g.setStroke(new BasicStroke(3, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND, 10f, dash, 0));
for (int i = 0; i < centers.size() - 1; i++) {
Point p1 = centers.get(i);
Point p2 = centers.get(i + 1);
int ctrlY = (p1.y + p2.y)/2 + 60 * ((i%2 == 0) ? 1 : -1);
CubicCurve2D curve = new CubicCurve2D.Float(
p1.x, p1.y, p1.x, ctrlY, p2.x, ctrlY, p2.x, p2.y
);
g.draw(curve);
}
}
// 文字换行处理(优化版)
private static void drawWrappedText(Graphics2D g, String[] lines, int x, int y, int maxWidth, int lineHeight) {
FontMetrics fm = g.getFontMetrics();
for (String line : lines) {
if (fm.stringWidth(line) <= maxWidth) {
g.drawString(line, x, y);
y += lineHeight;
} else {
// 处理长文本换行
StringBuilder currentLine = new StringBuilder();
for (char c : line.toCharArray()) {
if (fm.stringWidth(currentLine.toString() + c) > maxWidth) {
g.drawString(currentLine.toString(), x, y);
y += lineHeight;
currentLine.setLength(0);
}
currentLine.append(c);
}
if (currentLine.length() > 0) {
g.drawString(currentLine.toString(), x, y);
y += lineHeight;
}
}
}
}
}
关键修改说明
- 图片加载方式:
- 移除对
InfoPanel.getImage()
的依赖 - 新增
loadImageFromUrl()
方法,通过 HTTP 动态加载图片 - 添加图片加载失败时的降级处理(显示占位符)
- 移除对
- 性能优化:
- 使用静态
OkHttpClient
复用连接 - 图片按需加载,避免预先下载所有图片
- 使用静态
- 错误处理:
- 捕获
IOException
并显示错误提示 - 保持画卷生成流程不被单张图片失败中断
- 捕获
- 兼容性:
- 完全适配修改后的
InfoPanel
结构(imageUrl
+text
) - 保留原有布局和样式逻辑
- 完全适配修改后的
移除冗余代码
删除 ImageCropper
和本地图片裁剪逻辑。
修改总结
文件 | 原版本(本地文件) | 修改版本(URL处理) | 主要改动点 |
---|---|---|---|
ScrollHorizontalRollComposer | 直接使用BufferedImage : panels.get(i).getImage() |
新增loadImageFromUrl() 方法: java<br>BufferedImage img = loadImageFromUrl(panel.getImageUrl());<br> 支持HTTP下载图片,失败时显示占位符 |
1. 通过URL动态加载图片 2. 使用OkHttpClient 3. 错误降级处理 |
ImageCropper | 仅支持文件路径输入: ImageIO.read(new File(path)) |
支持两种输入方式: java<br>// 方式1:URL转临时文件<br>crop(downloadToTemp(url), w, h);<br><br>// 方式2:直接处理BufferedImage<br>crop(bufferedImage, w, h);<br> |
1. 增加日志 2. 支持内存图像处理 3. 优化缩放插值 |
ScrollService | 处理MultipartFile 上传: java<br>multipartFile.transferTo(tempFile);<br>cropImageFile(tempFile...);<br> |
完全重构为URL处理: java<br>// 动态生成背景图<br>BufferedImage bg = generateNewBackground();<br><br>// 直接使用URL创建面板<br>panels.add(new InfoPanel(url, text));<br><br>// 自动上传结果到图床<br>uploadScrollToImageHost(finalRoll);<br> |
1. 移除文件上传逻辑 2. 新增DALL-E背景生成 3. 集成图床自动上传 |
InfoPanel模型 | 存储BufferedImage : java<br>private BufferedImage image;<br> |
改为存储图片URL: java<br>private String imageUrl; // 存储URL<br> |
模型层解耦图像存储 |
ScrollFramer | 简单居中嵌入: java<br>g.drawImage(content, x, y, null);<br> |
智能缩放+裁剪: java<br>// 计算缩放比例<br>double scale = innerH / content.getHeight();<br><br>// 水平居中裁剪<br>if (cropX > 0) {<br> content.getSubimage(cropX, 0, w, h);<br>}<br> |
1. 自适应内容尺寸 2. 精确边框对齐 |
数据流
图片格式兼容性问题
- 使用的图片是
.webp
格式,但 Java 原生ImageIO
不支持 WebP。 - 错误日志中
BufferedImage.getWidth() failed
表明图片已下载但无法解析。
解决方案:
引入 WebP 支持库
<dependency>
<groupId>com.twelvemonkeys.imageio</groupId>
<artifactId>imageio-webp</artifactId>
<version>3.9.4</version>
</dependency>
同时,上传的图床的照片格式尽量使jpg
成果展示
测试版
最终版