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;