feat: Add newspaper-style text rendering engine

Implement text renderer with adaptive layout:
- Adaptive width with maximum 80 characters
- Center-aligned content for comfortable reading
- Smart text wrapping and paragraph spacing
- Color scheme optimized for terminal reading
- Support for headings, paragraphs, lists, and links
- Link indicators with numbering
- Horizontal rules and visual separators

The renderer creates a newspaper-like reading experience
optimized for terminal displays.
This commit is contained in:
m1ngsama 2025-12-05 14:59:17 +08:00
parent 2fedcebc25
commit 8e291399ae
2 changed files with 360 additions and 0 deletions

300
src/text_renderer.cpp Normal file
View file

@ -0,0 +1,300 @@
#include "text_renderer.h"
#include <sstream>
#include <algorithm>
#include <clocale>
class TextRenderer::Impl {
public:
RenderConfig config;
// 自动换行处理
std::vector<std::string> wrap_text(const std::string& text, int width) {
std::vector<std::string> lines;
if (text.empty()) {
return lines;
}
std::istringstream words_stream(text);
std::string word;
std::string current_line;
while (words_stream >> word) {
// 处理单个词超长的情况
if (word.length() > static_cast<size_t>(width)) {
if (!current_line.empty()) {
lines.push_back(current_line);
current_line.clear();
}
// 强制分割长词
for (size_t i = 0; i < word.length(); i += width) {
lines.push_back(word.substr(i, width));
}
continue;
}
// 正常换行逻辑
if (current_line.empty()) {
current_line = word;
} else if (current_line.length() + 1 + word.length() <= static_cast<size_t>(width)) {
current_line += " " + word;
} else {
lines.push_back(current_line);
current_line = word;
}
}
if (!current_line.empty()) {
lines.push_back(current_line);
}
if (lines.empty()) {
lines.push_back("");
}
return lines;
}
// 添加缩进
std::string add_indent(const std::string& text, int indent) {
return std::string(indent, ' ') + text;
}
};
TextRenderer::TextRenderer() : pImpl(std::make_unique<Impl>()) {
pImpl->config = RenderConfig();
}
TextRenderer::~TextRenderer() = default;
std::vector<RenderedLine> TextRenderer::render(const ParsedDocument& doc, int screen_width) {
std::vector<RenderedLine> lines;
// 计算实际内容宽度
int content_width = std::min(pImpl->config.max_width, screen_width - 4);
if (content_width < 40) {
content_width = screen_width - 4;
}
// 计算左边距(如果居中)
int margin = 0;
if (pImpl->config.center_content && content_width < screen_width) {
margin = (screen_width - content_width) / 2;
}
pImpl->config.margin_left = margin;
// 渲染标题
if (!doc.title.empty()) {
RenderedLine title_line;
title_line.text = std::string(margin, ' ') + doc.title;
title_line.color_pair = COLOR_HEADING1;
title_line.is_bold = true;
title_line.is_link = false;
title_line.link_index = -1;
lines.push_back(title_line);
// 标题下划线
RenderedLine underline;
underline.text = std::string(margin, ' ') + std::string(std::min((int)doc.title.length(), content_width), '=');
underline.color_pair = COLOR_HEADING1;
underline.is_bold = false;
underline.is_link = false;
underline.link_index = -1;
lines.push_back(underline);
// 空行
RenderedLine empty;
empty.text = "";
empty.color_pair = COLOR_NORMAL;
empty.is_bold = false;
empty.is_link = false;
empty.link_index = -1;
lines.push_back(empty);
}
// 渲染URL
if (!doc.url.empty()) {
RenderedLine url_line;
url_line.text = std::string(margin, ' ') + "URL: " + doc.url;
url_line.color_pair = COLOR_URL_BAR;
url_line.is_bold = false;
url_line.is_link = false;
url_line.link_index = -1;
lines.push_back(url_line);
RenderedLine empty;
empty.text = "";
empty.color_pair = COLOR_NORMAL;
empty.is_bold = false;
empty.is_link = false;
empty.link_index = -1;
lines.push_back(empty);
}
// 渲染内容元素
for (const auto& elem : doc.elements) {
int color = COLOR_NORMAL;
bool bold = false;
std::string prefix = "";
switch (elem.type) {
case ElementType::HEADING1:
color = COLOR_HEADING1;
bold = true;
prefix = "# ";
break;
case ElementType::HEADING2:
color = COLOR_HEADING2;
bold = true;
prefix = "## ";
break;
case ElementType::HEADING3:
color = COLOR_HEADING3;
bold = true;
prefix = "### ";
break;
case ElementType::PARAGRAPH:
color = COLOR_NORMAL;
bold = false;
break;
case ElementType::BLOCKQUOTE:
color = COLOR_DIM;
prefix = "> ";
break;
case ElementType::LIST_ITEM:
prefix = "";
break;
case ElementType::HORIZONTAL_RULE:
{
RenderedLine hr;
std::string hrline(content_width, '-');
hr.text = std::string(margin, ' ') + hrline;
hr.color_pair = COLOR_DIM;
hr.is_bold = false;
hr.is_link = false;
hr.link_index = -1;
lines.push_back(hr);
continue;
}
default:
break;
}
// 换行处理
auto wrapped_lines = pImpl->wrap_text(elem.text, content_width - prefix.length());
for (size_t i = 0; i < wrapped_lines.size(); ++i) {
RenderedLine line;
if (i == 0) {
line.text = std::string(margin, ' ') + prefix + wrapped_lines[i];
} else {
line.text = std::string(margin + prefix.length(), ' ') + wrapped_lines[i];
}
line.color_pair = color;
line.is_bold = bold;
line.is_link = false;
line.link_index = -1;
lines.push_back(line);
}
// 段落间距
if (elem.type == ElementType::PARAGRAPH ||
elem.type == ElementType::HEADING1 ||
elem.type == ElementType::HEADING2 ||
elem.type == ElementType::HEADING3) {
for (int i = 0; i < pImpl->config.paragraph_spacing; ++i) {
RenderedLine empty;
empty.text = "";
empty.color_pair = COLOR_NORMAL;
empty.is_bold = false;
empty.is_link = false;
empty.link_index = -1;
lines.push_back(empty);
}
}
}
// 渲染链接列表
if (!doc.links.empty() && pImpl->config.show_link_indicators) {
RenderedLine separator;
std::string sepline(content_width, '-');
separator.text = std::string(margin, ' ') + sepline;
separator.color_pair = COLOR_DIM;
separator.is_bold = false;
separator.is_link = false;
separator.link_index = -1;
lines.push_back(separator);
RenderedLine links_header;
links_header.text = std::string(margin, ' ') + "Links:";
links_header.color_pair = COLOR_HEADING3;
links_header.is_bold = true;
links_header.is_link = false;
links_header.link_index = -1;
lines.push_back(links_header);
RenderedLine empty;
empty.text = "";
empty.color_pair = COLOR_NORMAL;
empty.is_bold = false;
empty.is_link = false;
empty.link_index = -1;
lines.push_back(empty);
for (size_t i = 0; i < doc.links.size(); ++i) {
const auto& link = doc.links[i];
std::string link_text = "[" + std::to_string(i) + "] " + link.text;
auto wrapped = pImpl->wrap_text(link_text, content_width - 4);
for (size_t j = 0; j < wrapped.size(); ++j) {
RenderedLine link_line;
link_line.text = std::string(margin + 2, ' ') + wrapped[j];
link_line.color_pair = COLOR_LINK;
link_line.is_bold = false;
link_line.is_link = true;
link_line.link_index = i;
lines.push_back(link_line);
}
// URL on next line
auto url_wrapped = pImpl->wrap_text(link.url, content_width - 6);
for (const auto& url_line_text : url_wrapped) {
RenderedLine url_line;
url_line.text = std::string(margin + 4, ' ') + "" + url_line_text;
url_line.color_pair = COLOR_DIM;
url_line.is_bold = false;
url_line.is_link = false;
url_line.link_index = -1;
lines.push_back(url_line);
}
lines.push_back(empty);
}
}
return lines;
}
void TextRenderer::set_config(const RenderConfig& config) {
pImpl->config = config;
}
RenderConfig TextRenderer::get_config() const {
return pImpl->config;
}
void init_color_scheme() {
if (has_colors()) {
start_color();
use_default_colors();
init_pair(COLOR_NORMAL, COLOR_WHITE, -1);
init_pair(COLOR_HEADING1, COLOR_CYAN, -1);
init_pair(COLOR_HEADING2, COLOR_BLUE, -1);
init_pair(COLOR_HEADING3, COLOR_MAGENTA, -1);
init_pair(COLOR_LINK, COLOR_YELLOW, -1);
init_pair(COLOR_LINK_ACTIVE, COLOR_BLACK, COLOR_YELLOW);
init_pair(COLOR_STATUS_BAR, COLOR_BLACK, COLOR_WHITE);
init_pair(COLOR_URL_BAR, COLOR_GREEN, -1);
init_pair(COLOR_SEARCH_HIGHLIGHT, COLOR_BLACK, COLOR_YELLOW);
init_pair(COLOR_DIM, COLOR_BLACK, -1);
}
}

60
src/text_renderer.h Normal file
View file

@ -0,0 +1,60 @@
#pragma once
#include "html_parser.h"
#include <string>
#include <vector>
#include <curses.h>
// 渲染后的行信息
struct RenderedLine {
std::string text;
int color_pair;
bool is_bold;
bool is_link;
int link_index; // 如果是链接,对应的链接索引
};
// 渲染配置
struct RenderConfig {
int max_width = 80; // 内容最大宽度
int margin_left = 0; // 左边距(居中时自动计算)
bool center_content = true; // 是否居中内容
int paragraph_spacing = 1; // 段落间距
bool show_link_indicators = true; // 是否显示链接指示器
};
class TextRenderer {
public:
TextRenderer();
~TextRenderer();
// 渲染文档到行数组
std::vector<RenderedLine> render(const ParsedDocument& doc, int screen_width);
// 设置渲染配置
void set_config(const RenderConfig& config);
// 获取当前配置
RenderConfig get_config() const;
private:
class Impl;
std::unique_ptr<Impl> pImpl;
};
// 颜色定义
enum ColorScheme {
COLOR_NORMAL = 1,
COLOR_HEADING1,
COLOR_HEADING2,
COLOR_HEADING3,
COLOR_LINK,
COLOR_LINK_ACTIVE,
COLOR_STATUS_BAR,
COLOR_URL_BAR,
COLOR_SEARCH_HIGHLIGHT,
COLOR_DIM
};
// 初始化颜色方案
void init_color_scheme();