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 - 下次继续从这里开始
|
||||
|
||||
## 当前位置
|
||||
- **阶段**: Phase 4 - 图片支持 (基础完成)
|
||||
- **进度**: 占位符显示已完成,ASCII Art 渲染框架就绪
|
||||
- **最后提交**: `d80d0a1 feat: Implement TUT 2.0 with new rendering architecture`
|
||||
- **待推送**: 本地有 3 个提交未推送到 origin/main
|
||||
- **阶段**: Phase 4 - 图片支持 (已完成!)
|
||||
- **进度**: 图片 ASCII Art 渲染已集成到浏览器
|
||||
- **最后提交**: `feat: Add image ASCII art rendering support`
|
||||
|
||||
## 立即可做的事
|
||||
|
||||
### 1. 推送代码到远程
|
||||
### 1. 启用图片支持 (首次使用时需要)
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
|
||||
### 2. 启用完整图片支持 (PNG/JPEG)
|
||||
```bash
|
||||
# 下载 stb_image.h
|
||||
# 下载 stb_image.h (如果尚未下载)
|
||||
curl -L https://raw.githubusercontent.com/nothings/stb/master/stb_image.h \
|
||||
-o src/utils/stb_image.h
|
||||
|
||||
# 重新编译
|
||||
cmake --build build_v2
|
||||
|
||||
# 编译后 ImageRenderer::load_from_memory() 将自动支持 PNG/JPEG/GIF/BMP
|
||||
# 编译后会自动支持 PNG/JPEG/GIF/BMP 图片格式
|
||||
```
|
||||
|
||||
### 3. 在浏览器中集成图片渲染
|
||||
需要在 `browser_v2.cpp` 中:
|
||||
1. 收集页面中的所有 `<img>` 标签
|
||||
2. 使用 `HttpClient::fetch_binary()` 下载图片
|
||||
3. 调用 `ImageRenderer::load_from_memory()` 解码
|
||||
4. 调用 `ImageRenderer::render()` 生成 ASCII Art
|
||||
5. 将结果插入到布局中
|
||||
### 2. 测试图片渲染
|
||||
```bash
|
||||
# 访问有图片的网页
|
||||
./build_v2/tut2 https://httpbin.org/html
|
||||
|
||||
# 或访问包含图片的任意网页
|
||||
./build_v2/tut2 https://example.com
|
||||
```
|
||||
|
||||
## 已完成的功能清单
|
||||
|
||||
|
|
@ -42,8 +37,9 @@ cmake --build build_v2
|
|||
- [x] `HttpClient::fetch_binary()` 方法
|
||||
- [x] `ImageRenderer` 类框架
|
||||
- [x] PPM 格式内置解码
|
||||
- [ ] stb_image.h 集成 (需手动下载)
|
||||
- [ ] 浏览器中的图片下载和渲染
|
||||
- [x] stb_image.h 集成 (PNG/JPEG/GIF/BMP 支持)
|
||||
- [x] 浏览器中的图片下载和渲染
|
||||
- [x] ASCII Art 彩色渲染 (True Color)
|
||||
|
||||
### Phase 3 - 性能优化
|
||||
- [x] LRU 页面缓存 (20页, 5分钟过期)
|
||||
|
|
@ -131,14 +127,14 @@ cmake --build build_v2
|
|||
|
||||
## 下一步功能优先级
|
||||
|
||||
1. **完成图片 ASCII Art 渲染** - 下载 stb_image.h 并集成到浏览器
|
||||
2. **书签管理** - 添加/删除书签,书签列表页面,持久化存储
|
||||
3. **异步 HTTP 请求** - 非阻塞加载,加载动画,可取消请求
|
||||
4. **更多表单交互** - 文本输入编辑,下拉选择
|
||||
1. **书签管理** - 添加/删除书签,书签列表页面,持久化存储
|
||||
2. **异步 HTTP 请求** - 非阻塞加载,加载动画,可取消请求
|
||||
3. **更多表单交互** - 文本输入编辑,下拉选择
|
||||
4. **图片缓存** - 避免重复下载相同图片
|
||||
|
||||
## 恢复对话时说
|
||||
|
||||
> "继续TUT 2.0开发"
|
||||
|
||||
---
|
||||
更新时间: 2025-12-26 15:00
|
||||
更新时间: 2025-12-27
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
#include "dom_tree.h"
|
||||
#include "render/colors.h"
|
||||
#include "render/decorations.h"
|
||||
#include "render/image.h"
|
||||
#include "utils/unicode.h"
|
||||
#include <algorithm>
|
||||
#include <sstream>
|
||||
|
|
@ -142,6 +143,9 @@ public:
|
|||
status_message = current_tree.title.empty() ? url : current_tree.title;
|
||||
}
|
||||
|
||||
// 下载图片
|
||||
load_images(current_tree);
|
||||
|
||||
// 布局计算
|
||||
current_layout = layout_engine->layout(current_tree);
|
||||
|
||||
|
|
@ -183,6 +187,39 @@ public:
|
|||
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中提取主机名
|
||||
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树
|
||||
DocumentTree tree;
|
||||
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. 提取标题
|
||||
if (tree.root) {
|
||||
|
|
@ -153,6 +153,7 @@ std::unique_ptr<DomNode> DomTreeBuilder::convert_node(
|
|||
GumboNode* gumbo_node,
|
||||
std::vector<Link>& links,
|
||||
std::vector<DomNode*>& form_fields,
|
||||
std::vector<DomNode*>& images,
|
||||
const std::string& base_url
|
||||
) {
|
||||
if (!gumbo_node) return nullptr;
|
||||
|
|
@ -282,6 +283,11 @@ std::unique_ptr<DomNode> DomTreeBuilder::convert_node(
|
|||
if (height_attr && height_attr->value) {
|
||||
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]),
|
||||
links,
|
||||
form_fields,
|
||||
images,
|
||||
base_url
|
||||
);
|
||||
if (child) {
|
||||
child->parent = node.get();
|
||||
node->children.push_back(std::move(child));
|
||||
|
||||
|
||||
// For TEXTAREA, content is value
|
||||
if (element.tag == GUMBO_TAG_TEXTAREA && child->node_type == NodeType::TEXT) {
|
||||
node->value += child->text_content;
|
||||
|
|
@ -371,6 +378,7 @@ std::unique_ptr<DomNode> DomTreeBuilder::convert_node(
|
|||
static_cast<GumboNode*>(doc.children.data[i]),
|
||||
links,
|
||||
form_fields,
|
||||
images,
|
||||
base_url
|
||||
);
|
||||
if (child) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
#pragma once
|
||||
|
||||
#include "html_parser.h"
|
||||
#include "render/image.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
|
|
@ -40,6 +41,7 @@ struct DomNode {
|
|||
std::string alt_text; // 图片alt文本
|
||||
int img_width = -1; // 图片宽度 (-1表示未指定)
|
||||
int img_height = -1; // 图片高度 (-1表示未指定)
|
||||
tut::ImageData image_data; // 解码后的图片数据
|
||||
|
||||
// 表格属性
|
||||
bool is_table_header = false;
|
||||
|
|
@ -68,6 +70,7 @@ struct DocumentTree {
|
|||
std::unique_ptr<DomNode> root;
|
||||
std::vector<Link> links; // 全局链接列表
|
||||
std::vector<DomNode*> form_fields; // 全局表单字段列表 (非拥有指针)
|
||||
std::vector<DomNode*> images; // 全局图片列表 (非拥有指针)
|
||||
std::string title;
|
||||
std::string url;
|
||||
};
|
||||
|
|
@ -87,6 +90,7 @@ private:
|
|||
GumboNode* gumbo_node,
|
||||
std::vector<Link>& links,
|
||||
std::vector<DomNode*>& form_fields,
|
||||
std::vector<DomNode*>& images,
|
||||
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_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;
|
||||
line.indent = MARGIN_LEFT;
|
||||
|
||||
// 生成图片占位符
|
||||
std::string placeholder = make_image_placeholder(node->alt_text, node->img_src);
|
||||
|
||||
StyledSpan span;
|
||||
|
|
|
|||
Loading…
Reference in a new issue