feat: Add image ASCII art rendering support

- Add image data storage in DomNode for decoded images
- Collect image nodes in DocumentTree during parsing
- Download and decode images in browser_v2 before layout
- Render images as colored ASCII art using True Color
- Use stb_image for PNG/JPEG/GIF/BMP decoding (requires manual download)
- Fall back to placeholder for failed/missing images
This commit is contained in:
m1ngsama 2025-12-27 14:06:21 +08:00
parent 97a798f122
commit c6b1a9ac41
5 changed files with 152 additions and 29 deletions

View file

@ -1,37 +1,32 @@
# TUT 2.0 - 下次继续从这里开始 # TUT 2.0 - 下次继续从这里开始
## 当前位置 ## 当前位置
- **阶段**: Phase 4 - 图片支持 (基础完成) - **阶段**: Phase 4 - 图片支持 (已完成!)
- **进度**: 占位符显示已完成ASCII Art 渲染框架就绪 - **进度**: 图片 ASCII Art 渲染已集成到浏览器
- **最后提交**: `d80d0a1 feat: Implement TUT 2.0 with new rendering architecture` - **最后提交**: `feat: Add image ASCII art rendering support`
- **待推送**: 本地有 3 个提交未推送到 origin/main
## 立即可做的事 ## 立即可做的事
### 1. 推送代码到远程 ### 1. 启用图片支持 (首次使用时需要)
```bash ```bash
git push origin main # 下载 stb_image.h (如果尚未下载)
```
### 2. 启用完整图片支持 (PNG/JPEG)
```bash
# 下载 stb_image.h
curl -L https://raw.githubusercontent.com/nothings/stb/master/stb_image.h \ curl -L https://raw.githubusercontent.com/nothings/stb/master/stb_image.h \
-o src/utils/stb_image.h -o src/utils/stb_image.h
# 重新编译 # 重新编译
cmake --build build_v2 cmake --build build_v2
# 编译后 ImageRenderer::load_from_memory() 将自动支持 PNG/JPEG/GIF/BMP # 编译后会自动支持 PNG/JPEG/GIF/BMP 图片格式
``` ```
### 3. 在浏览器中集成图片渲染 ### 2. 测试图片渲染
需要在 `browser_v2.cpp` 中: ```bash
1. 收集页面中的所有 `<img>` 标签 # 访问有图片的网页
2. 使用 `HttpClient::fetch_binary()` 下载图片 ./build_v2/tut2 https://httpbin.org/html
3. 调用 `ImageRenderer::load_from_memory()` 解码
4. 调用 `ImageRenderer::render()` 生成 ASCII Art # 或访问包含图片的任意网页
5. 将结果插入到布局中 ./build_v2/tut2 https://example.com
```
## 已完成的功能清单 ## 已完成的功能清单
@ -42,8 +37,9 @@ cmake --build build_v2
- [x] `HttpClient::fetch_binary()` 方法 - [x] `HttpClient::fetch_binary()` 方法
- [x] `ImageRenderer` 类框架 - [x] `ImageRenderer` 类框架
- [x] PPM 格式内置解码 - [x] PPM 格式内置解码
- [ ] stb_image.h 集成 (需手动下载) - [x] stb_image.h 集成 (PNG/JPEG/GIF/BMP 支持)
- [ ] 浏览器中的图片下载和渲染 - [x] 浏览器中的图片下载和渲染
- [x] ASCII Art 彩色渲染 (True Color)
### Phase 3 - 性能优化 ### Phase 3 - 性能优化
- [x] LRU 页面缓存 (20页, 5分钟过期) - [x] LRU 页面缓存 (20页, 5分钟过期)
@ -131,14 +127,14 @@ cmake --build build_v2
## 下一步功能优先级 ## 下一步功能优先级
1. **完成图片 ASCII Art 渲染** - 下载 stb_image.h 并集成到浏览器 1. **书签管理** - 添加/删除书签,书签列表页面,持久化存储
2. **书签管理** - 添加/删除书签,书签列表页面,持久化存储 2. **异步 HTTP 请求** - 非阻塞加载,加载动画,可取消请求
3. **异步 HTTP 请求** - 非阻塞加载,加载动画,可取消请求 3. **更多表单交互** - 文本输入编辑,下拉选择
4. **更多表单交互** - 文本输入编辑,下拉选择 4. **图片缓存** - 避免重复下载相同图片
## 恢复对话时说 ## 恢复对话时说
> "继续TUT 2.0开发" > "继续TUT 2.0开发"
--- ---
更新时间: 2025-12-26 15:00 更新时间: 2025-12-27

View file

@ -2,6 +2,7 @@
#include "dom_tree.h" #include "dom_tree.h"
#include "render/colors.h" #include "render/colors.h"
#include "render/decorations.h" #include "render/decorations.h"
#include "render/image.h"
#include "utils/unicode.h" #include "utils/unicode.h"
#include <algorithm> #include <algorithm>
#include <sstream> #include <sstream>
@ -142,6 +143,9 @@ public:
status_message = current_tree.title.empty() ? url : current_tree.title; status_message = current_tree.title.empty() ? url : current_tree.title;
} }
// 下载图片
load_images(current_tree);
// 布局计算 // 布局计算
current_layout = layout_engine->layout(current_tree); current_layout = layout_engine->layout(current_tree);
@ -183,6 +187,39 @@ public:
page_cache[url] = std::move(entry); page_cache[url] = std::move(entry);
} }
// 下载并解码页面中的图片
void load_images(DocumentTree& tree) {
if (tree.images.empty()) {
return;
}
int loaded = 0;
int total = static_cast<int>(tree.images.size());
for (DomNode* img_node : tree.images) {
if (img_node->img_src.empty()) {
continue;
}
// 更新状态
loaded++;
status_message = "🖼 Loading image " + std::to_string(loaded) + "/" + std::to_string(total) + "...";
draw_screen();
// 下载图片
auto response = http_client.fetch_binary(img_node->img_src);
if (!response.is_success() || response.data.empty()) {
continue; // 跳过失败的图片
}
// 解码图片
tut::ImageData img_data = tut::ImageRenderer::load_from_memory(response.data);
if (img_data.is_valid()) {
img_node->image_data = std::move(img_data);
}
}
}
// 从URL中提取主机名 // 从URL中提取主机名
std::string extract_host(const std::string& url) { std::string extract_host(const std::string& url) {
// 简单提取:找到://之后的部分,到第一个/为止 // 简单提取:找到://之后的部分,到第一个/为止

View file

@ -136,7 +136,7 @@ DocumentTree DomTreeBuilder::build(const std::string& html, const std::string& b
// 2. 转换为DomNode树 // 2. 转换为DomNode树
DocumentTree tree; DocumentTree tree;
tree.url = base_url; tree.url = base_url;
tree.root = convert_node(output->root, tree.links, tree.form_fields, base_url); tree.root = convert_node(output->root, tree.links, tree.form_fields, tree.images, base_url);
// 3. 提取标题 // 3. 提取标题
if (tree.root) { if (tree.root) {
@ -153,6 +153,7 @@ std::unique_ptr<DomNode> DomTreeBuilder::convert_node(
GumboNode* gumbo_node, GumboNode* gumbo_node,
std::vector<Link>& links, std::vector<Link>& links,
std::vector<DomNode*>& form_fields, std::vector<DomNode*>& form_fields,
std::vector<DomNode*>& images,
const std::string& base_url const std::string& base_url
) { ) {
if (!gumbo_node) return nullptr; if (!gumbo_node) return nullptr;
@ -282,6 +283,11 @@ std::unique_ptr<DomNode> DomTreeBuilder::convert_node(
if (height_attr && height_attr->value) { if (height_attr && height_attr->value) {
try { node->img_height = std::stoi(height_attr->value); } catch (...) {} try { node->img_height = std::stoi(height_attr->value); } catch (...) {}
} }
// 添加到图片列表(用于后续下载)
if (!node->img_src.empty()) {
images.push_back(node.get());
}
} }
@ -334,12 +340,13 @@ std::unique_ptr<DomNode> DomTreeBuilder::convert_node(
static_cast<GumboNode*>(children->data[i]), static_cast<GumboNode*>(children->data[i]),
links, links,
form_fields, form_fields,
images,
base_url base_url
); );
if (child) { if (child) {
child->parent = node.get(); child->parent = node.get();
node->children.push_back(std::move(child)); node->children.push_back(std::move(child));
// For TEXTAREA, content is value // For TEXTAREA, content is value
if (element.tag == GUMBO_TAG_TEXTAREA && child->node_type == NodeType::TEXT) { if (element.tag == GUMBO_TAG_TEXTAREA && child->node_type == NodeType::TEXT) {
node->value += child->text_content; node->value += child->text_content;
@ -371,6 +378,7 @@ std::unique_ptr<DomNode> DomTreeBuilder::convert_node(
static_cast<GumboNode*>(doc.children.data[i]), static_cast<GumboNode*>(doc.children.data[i]),
links, links,
form_fields, form_fields,
images,
base_url base_url
); );
if (child) { if (child) {

View file

@ -1,6 +1,7 @@
#pragma once #pragma once
#include "html_parser.h" #include "html_parser.h"
#include "render/image.h"
#include <string> #include <string>
#include <vector> #include <vector>
#include <memory> #include <memory>
@ -40,6 +41,7 @@ struct DomNode {
std::string alt_text; // 图片alt文本 std::string alt_text; // 图片alt文本
int img_width = -1; // 图片宽度 (-1表示未指定) int img_width = -1; // 图片宽度 (-1表示未指定)
int img_height = -1; // 图片高度 (-1表示未指定) int img_height = -1; // 图片高度 (-1表示未指定)
tut::ImageData image_data; // 解码后的图片数据
// 表格属性 // 表格属性
bool is_table_header = false; bool is_table_header = false;
@ -68,6 +70,7 @@ struct DocumentTree {
std::unique_ptr<DomNode> root; std::unique_ptr<DomNode> root;
std::vector<Link> links; // 全局链接列表 std::vector<Link> links; // 全局链接列表
std::vector<DomNode*> form_fields; // 全局表单字段列表 (非拥有指针) std::vector<DomNode*> form_fields; // 全局表单字段列表 (非拥有指针)
std::vector<DomNode*> images; // 全局图片列表 (非拥有指针)
std::string title; std::string title;
std::string url; std::string url;
}; };
@ -87,6 +90,7 @@ private:
GumboNode* gumbo_node, GumboNode* gumbo_node,
std::vector<Link>& links, std::vector<Link>& links,
std::vector<DomNode*>& form_fields, std::vector<DomNode*>& form_fields,
std::vector<DomNode*>& images,
const std::string& base_url const std::string& base_url
); );

View file

@ -391,10 +391,88 @@ void LayoutEngine::layout_image_element(const DomNode* node, Context& /*ctx*/, s
block.margin_top = 0; block.margin_top = 0;
block.margin_bottom = 1; block.margin_bottom = 1;
// 检查是否有解码后的图片数据
if (node->image_data.is_valid()) {
// 渲染 ASCII Art
ImageRenderer renderer;
renderer.set_mode(ImageRenderer::Mode::BLOCKS);
renderer.set_color_enabled(true);
// 计算图片最大尺寸(留出左边距)
int max_width = content_width_;
int max_height = 30; // 限制高度
// 如果节点指定了尺寸,使用更小的值
if (node->img_width > 0) {
max_width = std::min(max_width, node->img_width);
}
if (node->img_height > 0) {
max_height = std::min(max_height, node->img_height / 2); // 考虑字符高宽比
}
AsciiImage ascii = renderer.render(node->image_data, max_width, max_height);
if (!ascii.lines.empty()) {
for (size_t i = 0; i < ascii.lines.size(); ++i) {
LayoutLine line;
line.indent = MARGIN_LEFT;
// 将每一行作为一个 span
// 但由于颜色可能不同,需要逐字符处理
const std::string& line_text = ascii.lines[i];
const std::vector<uint32_t>& line_colors = ascii.colors[i];
// 为了效率,尝试合并相同颜色的字符
size_t pos = 0;
while (pos < line_text.size()) {
// 获取当前字符的字节数UTF-8
int char_bytes = 1;
unsigned char c = line_text[pos];
if ((c & 0x80) == 0) {
char_bytes = 1;
} else if ((c & 0xE0) == 0xC0) {
char_bytes = 2;
} else if ((c & 0xF0) == 0xE0) {
char_bytes = 3;
} else if ((c & 0xF8) == 0xF0) {
char_bytes = 4;
}
// 获取颜色索引(基于显示宽度位置)
size_t color_idx = 0;
for (size_t j = 0; j < pos; ) {
unsigned char ch = line_text[j];
int bytes = 1;
if ((ch & 0x80) == 0) bytes = 1;
else if ((ch & 0xE0) == 0xC0) bytes = 2;
else if ((ch & 0xF0) == 0xE0) bytes = 3;
else if ((ch & 0xF8) == 0xF0) bytes = 4;
color_idx++;
j += bytes;
}
uint32_t color = (color_idx < line_colors.size()) ? line_colors[color_idx] : colors::FG_PRIMARY;
StyledSpan span;
span.text = line_text.substr(pos, char_bytes);
span.fg = color;
span.attrs = ATTR_NONE;
line.spans.push_back(span);
pos += char_bytes;
}
block.lines.push_back(line);
}
blocks.push_back(block);
return;
}
}
// 回退到占位符
LayoutLine line; LayoutLine line;
line.indent = MARGIN_LEFT; line.indent = MARGIN_LEFT;
// 生成图片占位符
std::string placeholder = make_image_placeholder(node->alt_text, node->img_src); std::string placeholder = make_image_placeholder(node->alt_text, node->img_src);
StyledSpan span; StyledSpan span;