Line data Source code
1 : #include "html_render.h"
2 : #include "html_parser.h"
3 : #include "html_medium.h"
4 : #include <stdlib.h>
5 : #include <string.h>
6 : #include <stdio.h>
7 : #include <ctype.h>
8 : #include <stdint.h>
9 :
10 : /* ── List stack ──────────────────────────────────────────────────────── */
11 :
12 : #define LIST_MAX 16
13 :
14 : typedef struct { int is_ol; int cnt; } ListFrame;
15 :
16 : /* ── Render state ────────────────────────────────────────────────────── */
17 :
18 : typedef struct {
19 : char *buf;
20 : size_t len, cap;
21 : int col; /* visible column (ANSI not counted) */
22 : int width; /* wrap width; 0 = no wrap */
23 : int ansi; /* emit ANSI escapes? */
24 : int bold; /* depth counter */
25 : int italic;
26 : int uline;
27 : int color_fg; /* depth: foreground color set by parse_style */
28 : int color_bg; /* depth: background color set by parse_style */
29 : int skip; /* depth: no output (script/style) */
30 : int pre; /* depth: no wrap */
31 : int pending_nl; /* buffered newlines to emit: 0, 1, or 2 */
32 : int bq; /* blockquote depth */
33 : ListFrame lists[LIST_MAX];
34 : int list_top;
35 : } RS;
36 :
37 : /* ── Buffer ──────────────────────────────────────────────────────────── */
38 :
39 27775 : static void rs_push(RS *rs, char c) {
40 27775 : if (rs->len + 2 > rs->cap) {
41 221 : size_t nc = rs->cap ? rs->cap * 2 : 512;
42 221 : char *t = realloc(rs->buf, nc);
43 221 : if (!t) return;
44 221 : rs->buf = t; rs->cap = nc;
45 : }
46 27775 : rs->buf[rs->len++] = c;
47 27775 : rs->buf[rs->len] = '\0';
48 : }
49 6730 : static void rs_str(RS *rs, const char *s) { for (; *s; s++) rs_push(rs, *s); }
50 3565 : static void rs_write(RS *rs, const char *s, int n)
51 17478 : { for (int i = 0; i < n; i++) rs_push(rs, s[i]); }
52 :
53 : /* ── UTF-8 helpers ───────────────────────────────────────────────────── */
54 :
55 14276 : static uint32_t utf8_adv(const char **p) {
56 14276 : unsigned char c = (unsigned char)**p;
57 : uint32_t cp; int ex;
58 14276 : if (c < 0x80) { cp = c; ex = 0; }
59 191 : else if (c < 0xC0) { (*p)++; return 0xFFFD; }
60 191 : else if (c < 0xE0) { cp = c & 0x1F; ex = 1; }
61 65 : else if (c < 0xF0) { cp = c & 0x0F; ex = 2; }
62 1 : else { cp = c & 0x07; ex = 3; }
63 14276 : (*p)++;
64 14533 : for (int i = 0; i < ex; i++) {
65 257 : if ((**p & 0xC0) != 0x80) return 0xFFFD;
66 257 : cp = (cp << 6) | (**p & 0x3F); (*p)++;
67 : }
68 14276 : return cp;
69 : }
70 :
71 64 : static int str_vis_width(const char *s) {
72 64 : int w = 0;
73 686 : while (*s) {
74 622 : if ((unsigned char)*s == 0x1B && s[1] == '[') {
75 4 : s += 2; while (*s && *s != 'm') s++; if (*s) s++;
76 2 : continue;
77 : }
78 620 : w += html_medium_char_width(utf8_adv(&s));
79 : }
80 64 : return w;
81 : }
82 :
83 : /* ── Newline / prefix management ─────────────────────────────────────── */
84 :
85 3030 : static void emit_bq_prefix(RS *rs) {
86 3030 : if (rs->col == 0 && rs->bq > 0) {
87 128 : for (int i = 0; i < rs->bq; i++) { rs_push(rs, '>'); rs_push(rs, ' '); }
88 64 : rs->col = rs->bq * 2;
89 : }
90 3030 : }
91 :
92 2374 : static void flush_nl(RS *rs) {
93 2374 : if (!rs->pending_nl) return;
94 : /* Count consecutive newlines already at the end of the buffer so we
95 : * never accumulate more than pending_nl in a row. */
96 792 : int trailing = 0;
97 792 : for (int i = (int)rs->len - 1; i >= 0 && rs->buf[i] == '\n'; i--)
98 0 : trailing++;
99 1777 : for (int i = trailing; i < rs->pending_nl; i++) rs_push(rs, '\n');
100 792 : rs->col = 0;
101 792 : rs->pending_nl = 0;
102 792 : emit_bq_prefix(rs);
103 : }
104 :
105 1864 : static void req_nl(RS *rs, int n) {
106 1864 : if (rs->pending_nl < n) rs->pending_nl = n;
107 1864 : }
108 804 : static void block_open(RS *rs) { if (rs->len > 0) req_nl(rs, 1); }
109 482 : static void block_close(RS *rs) { req_nl(rs, 1); }
110 193 : static void para_open(RS *rs) { if (rs->len > 0) req_nl(rs, 2); }
111 193 : static void para_close(RS *rs) { req_nl(rs, 2); }
112 :
113 : /* ── ANSI helpers ────────────────────────────────────────────────────── */
114 :
115 1436 : static void esc(RS *rs, const char *e) { if (rs->ansi) rs_str(rs, e); }
116 152 : static void open_bold(RS *rs) { if (rs->bold++ == 0) esc(rs, "\033[1m"); }
117 189 : static void close_bold(RS *rs) { if (--rs->bold == 0) esc(rs, "\033[22m"); }
118 78 : static void open_italic(RS *rs) { if (rs->italic++ == 0) esc(rs, "\033[3m"); }
119 111 : static void close_italic(RS *rs) { if (--rs->italic == 0) esc(rs, "\033[23m"); }
120 71 : static void open_uline(RS *rs) { if (rs->uline++ == 0) esc(rs, "\033[4m"); }
121 109 : static void close_uline(RS *rs) { if (--rs->uline == 0) esc(rs, "\033[24m"); }
122 :
123 : /* ── Inline CSS ──────────────────────────────────────────────────────── */
124 :
125 285 : static int hex_val(char c) {
126 285 : if (c>='0'&&c<='9') return c-'0';
127 38 : if (c>='a'&&c<='f') return c-'a'+10;
128 38 : if (c>='A'&&c<='F') return c-'A'+10;
129 6 : return 0;
130 : }
131 : static const struct { const char *name; int r,g,b; } CSS_COLORS[] = {
132 : {"black",0,0,0},{"silver",192,192,192},{"gray",128,128,128},
133 : {"white",255,255,255},{"maroon",128,0,0},{"red",255,0,0},
134 : {"purple",128,0,128},{"fuchsia",255,0,255},{"green",0,128,0},
135 : {"lime",0,255,0},{"olive",128,128,0},{"yellow",255,255,0},
136 : {"navy",0,0,128},{"blue",0,0,255},{"teal",0,128,128},{"aqua",0,255,255},
137 : {NULL,0,0,0}
138 : };
139 155 : static void apply_color(RS *rs, const char *v, int fg) {
140 252 : if (!rs->ansi) return;
141 : /* Background colors are never emitted: they break dark-theme terminals
142 : * and produce unreadable combinations when the email author's palette
143 : * does not match the user's terminal theme. */
144 155 : if (!fg) return;
145 109 : int r=-1,g=-1,b=-1;
146 109 : while (*v==' ') v++;
147 109 : if (*v=='#') {
148 66 : v++;
149 66 : size_t len=strlen(v); while(len>0&&v[len-1]==' ') len--;
150 66 : if (len==6) {
151 29 : r=hex_val(v[0])*16+hex_val(v[1]);
152 29 : g=hex_val(v[2])*16+hex_val(v[3]);
153 29 : b=hex_val(v[4])*16+hex_val(v[5]);
154 37 : } else if (len==3) {
155 37 : r=hex_val(v[0])*17; g=hex_val(v[1])*17; b=hex_val(v[2])*17;
156 : }
157 : } else {
158 291 : for (int i=0; CSS_COLORS[i].name; i++) {
159 291 : size_t nl=strlen(CSS_COLORS[i].name);
160 291 : if (strncasecmp(v, CSS_COLORS[i].name, nl)==0) {
161 43 : r=CSS_COLORS[i].r; g=CSS_COLORS[i].g; b=CSS_COLORS[i].b; break;
162 : }
163 : }
164 : }
165 109 : if (r<0) return;
166 : /* Suppress dark foreground colors (max component < 160): they are
167 : * unreadable on dark-theme terminals and common in newsletter HTML
168 : * (e.g. #333, #666, gray). Only bright colours are emitted. */
169 109 : int mx = r > g ? (r > b ? r : b) : (g > b ? g : b);
170 109 : if (mx < 160) return;
171 58 : char e[32]; snprintf(e,sizeof(e),"\033[38;2;%d;%d;%dm",r,g,b);
172 58 : rs_str(rs, e);
173 58 : rs->color_fg++;
174 : }
175 278 : static void parse_style(RS *rs, const char *style) {
176 278 : if (!style || !rs->ansi) return;
177 166 : const char *p = style;
178 429 : while (*p) {
179 335 : while (*p==' '||*p=='\t') p++;
180 2851 : const char *ps=p; while(*p&&*p!=':') p++;
181 263 : if (!*p) break;
182 263 : size_t pl=(size_t)(p-ps); while(pl>0&&ps[pl-1]==' ') pl--;
183 407 : p++; while(*p==' ') p++;
184 1719 : const char *vs=p; while(*p&&*p!=';') p++;
185 263 : size_t vl=(size_t)(p-vs); if(*p==';') p++;
186 263 : char prop[32]={0},val[64]={0};
187 263 : if(pl<sizeof(prop)) memcpy(prop,ps,pl);
188 263 : if(vl>0&&vl<sizeof(val)) memcpy(val,vs,vl);
189 263 : if (!strcasecmp(prop,"font-weight")&&!strncasecmp(val,"bold",4)) { esc(rs,"\033[1m"); rs->bold++; }
190 226 : else if (!strcasecmp(prop,"font-style")&&!strncasecmp(val,"italic",6)) { esc(rs,"\033[3m"); rs->italic++; }
191 193 : else if (!strcasecmp(prop,"text-decoration")&&!strncasecmp(val,"underline",9)){ esc(rs,"\033[4m"); rs->uline++; }
192 155 : else if (!strcasecmp(prop,"color")) apply_color(rs,val,1);
193 46 : else if (!strcasecmp(prop,"background-color")) apply_color(rs,val,0);
194 : }
195 : }
196 :
197 : /* ── Text emission with word wrap ────────────────────────────────────── */
198 :
199 73 : static void emit_wrap_nl(RS *rs) {
200 : /* remove trailing space before the newline */
201 73 : if (rs->len > 0 && rs->buf[rs->len-1] == ' ') {
202 9 : rs->len--; rs->buf[rs->len] = '\0'; rs->col--;
203 : }
204 73 : rs_push(rs, '\n');
205 73 : rs->col = 0;
206 73 : emit_bq_prefix(rs);
207 73 : }
208 :
209 1708 : static void emit_text(RS *rs, const char *text) {
210 1708 : if (!text || !*text || rs->skip) return;
211 1708 : flush_nl(rs);
212 1708 : emit_bq_prefix(rs);
213 :
214 1708 : if (rs->pre) {
215 1064 : for (const char *p = text; *p; ) {
216 1001 : if (*p == '\r') { p++; continue; }
217 1001 : if (*p == '\n') {
218 2 : rs_push(rs, '\n'); rs->col = 0;
219 2 : emit_bq_prefix(rs);
220 2 : p++; continue;
221 : }
222 999 : const char *s = p;
223 999 : rs->col += html_medium_char_width(utf8_adv(&p));
224 999 : rs_write(rs, s, (int)(p - s));
225 : }
226 63 : return;
227 : }
228 :
229 : /* Normal mode: word-wrap */
230 5775 : for (const char *p = text; *p; ) {
231 4130 : if (isspace((unsigned char)*p)) {
232 : /* Collapse whitespace; only emit space if not at line start */
233 1564 : int at_start = (rs->col <= rs->bq * 2);
234 1564 : int already_space = (rs->len > 0 && rs->buf[rs->len-1] == ' ');
235 1564 : if (!at_start && !already_space) {
236 1496 : rs_push(rs, ' ');
237 1496 : rs->col++;
238 : }
239 3189 : while (*p && isspace((unsigned char)*p)) p++;
240 1564 : continue;
241 : }
242 :
243 : /* Collect one word */
244 2566 : const char *ws = p;
245 2566 : int ww = 0;
246 15223 : while (*p && !isspace((unsigned char)*p)) {
247 12657 : const char *q = p;
248 12657 : ww += html_medium_char_width(utf8_adv(&p));
249 : (void)q;
250 : }
251 2566 : int wlen = (int)(p - ws);
252 :
253 : /* URL tokens (http://, https://, ftp://, mailto:) are always placed
254 : * on their own line so terminal URL-recognition works reliably.
255 : * They are never broken regardless of width. */
256 3363 : int is_url = (wlen >= 6) && (
257 797 : strncmp(ws, "http://", 7) == 0 ||
258 794 : strncmp(ws, "https://", 8) == 0 ||
259 723 : strncmp(ws, "ftp://", 6) == 0 ||
260 723 : strncmp(ws, "mailto:", 7) == 0);
261 :
262 2566 : if (is_url) {
263 74 : if (rs->col > rs->bq * 2) emit_wrap_nl(rs); /* start own line */
264 74 : esc(rs, "\033[34m"); /* blue */
265 74 : rs_write(rs, ws, wlen);
266 74 : esc(rs, "\033[39m"); /* reset fg */
267 74 : rs->col += ww;
268 74 : rs_push(rs, '\n'); /* trailing newline: next content fresh line */
269 74 : rs->col = 0;
270 74 : emit_bq_prefix(rs);
271 74 : continue;
272 : }
273 :
274 : /* Wrap if needed (never wrap an otherwise-empty line) */
275 2492 : if (rs->width > 0 && rs->col > rs->bq * 2 && rs->col + ww > rs->width)
276 4 : emit_wrap_nl(rs);
277 :
278 2492 : rs_write(rs, ws, wlen);
279 2492 : rs->col += ww;
280 : }
281 : }
282 :
283 : /* ── Whitespace helpers ──────────────────────────────────────────────── */
284 :
285 : /** Returns 1 if s contains only ASCII whitespace, U+00A0 nbsp, U+200C zwnj,
286 : * U+200D zwj, or U+00AD soft-hyphen — i.e. invisible/non-printing content. */
287 66 : static int is_blank_str(const char *s) {
288 66 : const unsigned char *p = (const unsigned char *)s;
289 70 : while (*p) {
290 67 : if (*p <= ' ') { p++; continue; }
291 65 : if (p[0]==0xC2 && p[1]==0xA0) { p+=2; continue; } /* nbsp */
292 64 : if (p[0]==0xC2 && p[1]==0xAD) { p+=2; continue; } /* shy */
293 64 : if (p[0]==0xE2 && p[1]==0x80 && p[2]==0x8C) { p+=3; continue; } /* zwnj */
294 63 : if (p[0]==0xE2 && p[1]==0x80 && p[2]==0x8D) { p+=3; continue; } /* zwj */
295 63 : return 0;
296 : }
297 3 : return 1;
298 : }
299 :
300 : /** Collapses runs of >1 consecutive blank lines to exactly one blank line.
301 : * Also trims trailing ASCII/nbsp/zwnj whitespace from each line.
302 : * Preserves up to one trailing blank line.
303 : * Takes ownership of s (frees it); returns a new heap string (or s on OOM). */
304 221 : static char *compact_lines(char *s) {
305 221 : if (!s) return s;
306 221 : size_t n = strlen(s);
307 221 : char *out = malloc(n + 1);
308 221 : if (!out) return s;
309 :
310 221 : const unsigned char *p = (const unsigned char *)s;
311 221 : char *q = out;
312 221 : int blank_pending = 0; /* at most 1: whether a blank line is pending */
313 221 : int have_content = 0;
314 :
315 1513 : while (*p) {
316 1292 : const unsigned char *ls = p;
317 27924 : while (*p && *p != '\n') p++;
318 1292 : int had_nl = (*p == '\n');
319 1292 : const unsigned char *le = p;
320 1292 : if (had_nl) p++;
321 :
322 : /* Trim trailing invisible chars (ASCII ws, nbsp C2A0, shy C2AD, zwnj/zwj E2808C/8D) */
323 1356 : while (le > ls) {
324 1162 : if (le[-1] <= ' ') { le--; continue; }
325 1098 : if (le>=ls+2 && le[-2]==0xC2 && (le[-1]==0xA0||le[-1]==0xAD)) { le-=2; continue; }
326 1098 : if (le>=ls+3 && le[-3]==0xE2 && le[-2]==0x80 &&
327 0 : (le[-1]==0x8C||le[-1]==0x8D)) { le-=3; continue; }
328 1098 : break;
329 : }
330 :
331 1292 : if (le == ls) { /* blank line */
332 194 : if (had_nl) blank_pending = 1;
333 : } else { /* non-blank line */
334 1098 : if (blank_pending && have_content) *q++ = '\n';
335 1098 : blank_pending = 0;
336 1098 : have_content = 1;
337 1098 : memcpy(q, ls, (size_t)(le - ls));
338 1098 : q += (size_t)(le - ls);
339 1098 : if (had_nl) *q++ = '\n';
340 : }
341 : }
342 : /* Preserve at most one trailing blank line */
343 221 : if (blank_pending) *q++ = '\n';
344 :
345 221 : *q = '\0';
346 221 : free(s);
347 221 : return out;
348 : }
349 :
350 : /* ── Tag open / close ────────────────────────────────────────────────── */
351 :
352 : static void traverse(RS *rs, const HtmlNode *node);
353 :
354 1902 : static void tag_open(RS *rs, const HtmlNode *node) {
355 1902 : if (!node->tag) return;
356 1902 : const char *t = node->tag;
357 :
358 : /* Inline styles (applied before tag-specific behavior) */
359 1902 : const char *style = html_attr_get(node, "style");
360 1902 : if (style) parse_style(rs, style);
361 :
362 1902 : if (!strcmp(t,"b")||!strcmp(t,"strong")) open_bold(rs);
363 1813 : else if (!strcmp(t,"i")||!strcmp(t,"em")) open_italic(rs);
364 1735 : else if (!strcmp(t,"u")) open_uline(rs);
365 1664 : else if (!strcmp(t,"s")||!strcmp(t,"del")||!strcmp(t,"strike")) esc(rs,"\033[9m");
366 1534 : else if (!strcmp(t,"br")) req_nl(rs, 1);
367 1533 : else if (!strcmp(t,"hr")) {
368 62 : block_open(rs); flush_nl(rs); emit_bq_prefix(rs);
369 62 : int w = rs->width > 0 ? rs->width - rs->col : 20;
370 4738 : for (int i = 0; i < w; i++) rs_push(rs, '-');
371 62 : rs->col += w;
372 62 : block_close(rs);
373 : }
374 1471 : else if (!strcmp(t,"p")) para_open(rs);
375 1341 : else if (!strcmp(t,"div")||!strcmp(t,"article")||!strcmp(t,"section")||
376 1246 : !strcmp(t,"main")||!strcmp(t,"header")||!strcmp(t,"footer")||
377 1341 : !strcmp(t,"nav")||!strcmp(t,"aside")) block_open(rs);
378 1246 : else if (t[0]=='h' && t[1]>='1' && t[1]<='6' && !t[2]) {
379 63 : para_open(rs); open_bold(rs);
380 : }
381 1183 : else if (!strcmp(t,"ul")) {
382 65 : if (rs->list_top < LIST_MAX) rs->lists[rs->list_top++] = (ListFrame){0, 0};
383 65 : block_open(rs);
384 : }
385 1118 : else if (!strcmp(t,"ol")) {
386 63 : if (rs->list_top < LIST_MAX) rs->lists[rs->list_top++] = (ListFrame){1, 0};
387 63 : block_open(rs);
388 : }
389 1055 : else if (!strcmp(t,"li")) {
390 252 : block_open(rs);
391 252 : flush_nl(rs);
392 252 : emit_bq_prefix(rs);
393 252 : if (rs->list_top > 0 && rs->lists[rs->list_top-1].is_ol) {
394 125 : rs->lists[rs->list_top-1].cnt++;
395 : char buf[16];
396 125 : int n = snprintf(buf, sizeof(buf), "%d. ", rs->lists[rs->list_top-1].cnt);
397 125 : rs_str(rs, buf);
398 125 : rs->col += n;
399 : } else {
400 : /* U+2022 BULLET: UTF-8 E2 80 A2 */
401 127 : rs_push(rs,(char)0xE2); rs_push(rs,(char)0x80); rs_push(rs,(char)0xA2);
402 127 : rs_push(rs, ' ');
403 127 : rs->col += 2; /* bullet=1 col, space=1 col */
404 : }
405 : }
406 803 : else if (!strcmp(t,"blockquote")) {
407 64 : block_open(rs);
408 64 : rs->bq++;
409 : /* if something was already on the line, start fresh */
410 64 : if (rs->col > 0) { flush_nl(rs); } else emit_bq_prefix(rs);
411 : }
412 739 : else if (!strcmp(t,"pre")) { block_open(rs); rs->pre++; }
413 676 : else if (!strcmp(t,"code")) { /* inline: no special formatting */ }
414 676 : else if (!strcmp(t,"img")) {
415 66 : const char *alt = html_attr_get(node, "alt");
416 66 : if (alt && *alt && !is_blank_str(alt)) {
417 63 : flush_nl(rs); emit_bq_prefix(rs);
418 63 : rs_push(rs, '[');
419 63 : rs_str(rs, alt);
420 63 : rs_push(rs, ']');
421 63 : rs->col += 2 + str_vis_width(alt);
422 : }
423 : }
424 610 : else if (!strcmp(t,"a")) {
425 74 : const char *href = html_attr_get(node, "href");
426 74 : if (href && *href && href[0] != '#' && strncmp(href, "javascript:", 11) != 0)
427 70 : if (rs->ansi) { esc(rs, "\033[34m"); rs->color_fg++; }
428 : }
429 536 : else if (!strcmp(t,"td")||!strcmp(t,"th")) {
430 137 : if (rs->col > rs->bq * 2) { rs_push(rs, '\t'); rs->col++; }
431 : }
432 399 : else if (!strcmp(t,"tr")||!strcmp(t,"table")) block_open(rs);
433 259 : else if (!strcmp(t,"script")||!strcmp(t,"style")) rs->skip++;
434 259 : else if (!strcmp(t,"textarea")||!strcmp(t,"button")) block_open(rs);
435 259 : else if (!strcmp(t,"input")) {
436 1 : const char *val = html_attr_get(node, "value");
437 1 : if (val && *val) {
438 1 : flush_nl(rs); emit_bq_prefix(rs);
439 1 : rs_str(rs, val);
440 1 : rs->col += str_vis_width(val);
441 : }
442 : }
443 : /* __root__ and unknown tags: traverse children unchanged */
444 : }
445 :
446 1902 : static void tag_close(RS *rs, const HtmlNode *node) {
447 1902 : if (!node->tag) return;
448 1902 : const char *t = node->tag;
449 :
450 1902 : if (!strcmp(t,"b")||!strcmp(t,"strong")) close_bold(rs);
451 1813 : else if (!strcmp(t,"i")||!strcmp(t,"em")) close_italic(rs);
452 1735 : else if (!strcmp(t,"u")) close_uline(rs);
453 1664 : else if (!strcmp(t,"s")||!strcmp(t,"del")||!strcmp(t,"strike")) esc(rs,"\033[29m");
454 1534 : else if (!strcmp(t,"p")) para_close(rs);
455 1404 : else if (!strcmp(t,"div")||!strcmp(t,"article")||!strcmp(t,"section")||
456 1309 : !strcmp(t,"main")||!strcmp(t,"header")||!strcmp(t,"footer")||
457 1404 : !strcmp(t,"nav")||!strcmp(t,"aside")) block_close(rs);
458 1309 : else if (t[0]=='h' && t[1]>='1' && t[1]<='6' && !t[2]) {
459 63 : close_bold(rs); para_close(rs);
460 : }
461 1246 : else if (!strcmp(t,"ul")||!strcmp(t,"ol")) {
462 128 : if (rs->list_top > 0) rs->list_top--;
463 128 : block_close(rs);
464 : }
465 1118 : else if (!strcmp(t,"li")) req_nl(rs, 1);
466 866 : else if (!strcmp(t,"blockquote")) {
467 64 : if (rs->bq > 0) rs->bq--;
468 64 : block_close(rs);
469 : }
470 802 : else if (!strcmp(t,"pre")) { if (rs->pre>0) rs->pre--; block_close(rs); }
471 739 : else if (!strcmp(t,"a")) {
472 74 : const char *href = html_attr_get(node, "href");
473 74 : if (href && *href && href[0] != '#' &&
474 71 : strncmp(href, "javascript:", 11) != 0) {
475 70 : if (rs->ansi && rs->color_fg > 0) { esc(rs, "\033[39m"); rs->color_fg--; }
476 70 : esc(rs, "\033[34m");
477 70 : emit_text(rs, href);
478 70 : esc(rs, "\033[39m");
479 : }
480 : }
481 665 : else if (!strcmp(t,"script")||!strcmp(t,"style")) { if (rs->skip>0) rs->skip--; }
482 665 : else if (!strcmp(t,"tr")) req_nl(rs, 1);
483 595 : else if (!strcmp(t,"table")) block_close(rs);
484 525 : else if (!strcmp(t,"textarea")||!strcmp(t,"button")) block_close(rs);
485 : }
486 :
487 : /* ── Tree traversal ──────────────────────────────────────────────────── */
488 :
489 3540 : static void traverse(RS *rs, const HtmlNode *node) {
490 3540 : if (!node) return;
491 3540 : if (node->type == HTML_NODE_TEXT) {
492 1638 : emit_text(rs, node->text);
493 1638 : return;
494 : }
495 : /* Snapshot style depth so parse_style side-effects from inline
496 : * style= attributes are balanced even when tag_close has no handler
497 : * for this tag (e.g. <a>, <span>, <td> with style="…"). */
498 1902 : int bold_sv = rs->bold;
499 1902 : int italic_sv = rs->italic;
500 1902 : int uline_sv = rs->uline;
501 1902 : int color_fg_sv = rs->color_fg;
502 1902 : int color_bg_sv = rs->color_bg;
503 1902 : tag_open(rs, node);
504 5082 : for (const HtmlNode *c = node->first_child; c; c = c->next_sibling)
505 3180 : traverse(rs, c);
506 1902 : tag_close(rs, node);
507 : /* Close any depth-tracked style that tag_close left open */
508 1940 : while (rs->uline > uline_sv) close_uline(rs);
509 1935 : while (rs->italic > italic_sv) close_italic(rs);
510 1939 : while (rs->bold > bold_sv) close_bold(rs);
511 1902 : if (rs->ansi) {
512 854 : if (rs->color_fg > color_fg_sv) { esc(rs, "\033[39m"); rs->color_fg = color_fg_sv; }
513 854 : if (rs->color_bg > color_bg_sv) { esc(rs, "\033[49m"); rs->color_bg = color_bg_sv; }
514 : }
515 : }
516 :
517 : /* ── Public API ──────────────────────────────────────────────────────── */
518 :
519 228 : char *html_render(const char *html, int width, int ansi) {
520 228 : if (!html) return NULL;
521 :
522 227 : HtmlNode *root = html_parse(html);
523 227 : if (!root) {
524 0 : char *empty = malloc(1);
525 0 : if (empty) *empty = '\0';
526 0 : return empty;
527 : }
528 :
529 : RS rs;
530 227 : memset(&rs, 0, sizeof(rs));
531 227 : rs.width = width;
532 227 : rs.ansi = ansi;
533 :
534 : /* Traverse root's children (root itself is synthetic __root__) */
535 587 : for (const HtmlNode *c = root->first_child; c; c = c->next_sibling)
536 360 : traverse(&rs, c);
537 :
538 : /* Flush trailing newlines */
539 227 : flush_nl(&rs);
540 :
541 227 : html_node_free(root);
542 :
543 227 : if (!rs.buf) {
544 6 : char *empty = malloc(1);
545 6 : if (empty) *empty = '\0';
546 6 : return empty;
547 : }
548 221 : return compact_lines(rs.buf);
549 : }
|