diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md
index f2582e0..a0f4a06 100644
--- a/NEXT_STEPS.md
+++ b/NEXT_STEPS.md
@@ -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. 收集页面中的所有 `
` 标签
-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
diff --git a/src/browser_v2.cpp b/src/browser_v2.cpp
index 2527693..fb11a79 100644
--- a/src/browser_v2.cpp
+++ b/src/browser_v2.cpp
@@ -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
#include
@@ -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(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) {
// 简单提取:找到://之后的部分,到第一个/为止
diff --git a/src/dom_tree.cpp b/src/dom_tree.cpp
index 7374c18..65e64bd 100644
--- a/src/dom_tree.cpp
+++ b/src/dom_tree.cpp
@@ -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 DomTreeBuilder::convert_node(
GumboNode* gumbo_node,
std::vector& links,
std::vector& form_fields,
+ std::vector& images,
const std::string& base_url
) {
if (!gumbo_node) return nullptr;
@@ -282,6 +283,11 @@ std::unique_ptr 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 DomTreeBuilder::convert_node(
static_cast(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 DomTreeBuilder::convert_node(
static_cast(doc.children.data[i]),
links,
form_fields,
+ images,
base_url
);
if (child) {
diff --git a/src/dom_tree.h b/src/dom_tree.h
index 21dcd4c..c833e68 100644
--- a/src/dom_tree.h
+++ b/src/dom_tree.h
@@ -1,6 +1,7 @@
#pragma once
#include "html_parser.h"
+#include "render/image.h"
#include
#include
#include
@@ -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 root;
std::vector links; // 全局链接列表
std::vector form_fields; // 全局表单字段列表 (非拥有指针)
+ std::vector images; // 全局图片列表 (非拥有指针)
std::string title;
std::string url;
};
@@ -87,6 +90,7 @@ private:
GumboNode* gumbo_node,
std::vector& links,
std::vector& form_fields,
+ std::vector& images,
const std::string& base_url
);
diff --git a/src/render/layout.cpp b/src/render/layout.cpp
index ca84776..3a8d927 100644
--- a/src/render/layout.cpp
+++ b/src/render/layout.cpp
@@ -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& 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;