mirror of
https://github.com/m1ngsama/TUT.git
synced 2026-02-08 00:54:05 +00:00
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:
parent
97a798f122
commit
c6b1a9ac41
5 changed files with 152 additions and 29 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
// 简单提取:找到://之后的部分,到第一个/为止
|
// 简单提取:找到://之后的部分,到第一个/为止
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue