1#include "htmlconv.h"
2#include <stdlib.h>
3#include <string.h>
4#include <ctype.h>
5
6// --- Dynamic buffer ---
7
8typedef struct {
9 char* data;
10 size_t len;
11 size_t cap;
12} Buffer;
13
14static void buf_init(Buffer* b) {
15 b->data = NULL;
16 b->len = 0;
17 b->cap = 0;
18}
19
20static void buf_ensure(Buffer* b, size_t extra) {
21 size_t needed = b->len + extra;
22 if (needed <= b->cap) return;
23 size_t newcap = b->cap ? b->cap * 2 : 256;
24 while (newcap < needed) newcap *= 2;
25 b->data = (char*)realloc(b->data, newcap);
26 b->cap = newcap;
27}
28
29static void buf_append(Buffer* b, const char* s, size_t n) {
30 if (n == 0) return;
31 buf_ensure(b, n);
32 memcpy(b->data + b->len, s, n);
33 b->len += n;
34}
35
36static void buf_append_char(Buffer* b, char c) {
37 buf_ensure(b, 1);
38 b->data[b->len++] = c;
39}
40
41static char* buf_finish(Buffer* b) {
42 buf_append_char(b, '\0');
43 return b->data;
44}
45
46static void buf_free(Buffer* b) {
47 free(b->data);
48 b->data = NULL;
49 b->len = 0;
50 b->cap = 0;
51}
52
53// --- Result helpers ---
54
55static void result_init(HTMLConvertResult* r) {
56 r->elements = NULL;
57 r->count = 0;
58 r->cap = 0;
59 r->ok = 0;
60}
61
62static HTMLElement* result_add(HTMLConvertResult* r) {
63 if (r->count >= r->cap) {
64 int newcap = r->cap ? r->cap * 2 : 32;
65 r->elements = (HTMLElement*)realloc(r->elements, sizeof(HTMLElement) * newcap);
66 r->cap = newcap;
67 }
68 HTMLElement* e = &r->elements[r->count++];
69 e->type = HELEM_TEXT;
70 e->text = NULL;
71 e->attr1 = NULL;
72 e->attr2 = NULL;
73 return e;
74}
75
76// Flush accumulated text buffer as a TEXT element.
77static void flush_text(HTMLConvertResult* r, Buffer* buf) {
78 if (buf->len == 0) return;
79 HTMLElement* e = result_add(r);
80 e->type = HELEM_TEXT;
81 e->text = buf_finish(buf);
82 buf_init(buf);
83}
84
85// --- HTML entity decoding ---
86
87static size_t decode_entity(const char* s, size_t len, Buffer* out) {
88 // s points to '&', returns number of chars consumed
89 if (len < 2) { buf_append_char(out, '&'); return 1; }
90
91 // Find the ';'
92 size_t end = 1;
93 while (end < len && end < 12 && s[end] != ';') end++;
94 if (end >= len || s[end] != ';') { buf_append_char(out, '&'); return 1; }
95
96 size_t ent_len = end - 1; // length of entity name (between & and ;)
97 const char* name = s + 1;
98
99 // Numeric entities
100 if (ent_len >= 2 && name[0] == '#') {
101 unsigned long cp = 0;
102 if (name[1] == 'x' || name[1] == 'X') {
103 for (size_t i = 2; i < ent_len; i++) {
104 char c = name[i];
105 if (c >= '0' && c <= '9') cp = cp * 16 + (c - '0');
106 else if (c >= 'a' && c <= 'f') cp = cp * 16 + 10 + (c - 'a');
107 else if (c >= 'A' && c <= 'F') cp = cp * 16 + 10 + (c - 'A');
108 else break;
109 }
110 } else {
111 for (size_t i = 1; i < ent_len; i++) {
112 if (name[i] >= '0' && name[i] <= '9') cp = cp * 10 + (name[i] - '0');
113 else break;
114 }
115 }
116 // Encode as UTF-8
117 if (cp < 0x80) {
118 buf_append_char(out, (char)cp);
119 } else if (cp < 0x800) {
120 buf_append_char(out, (char)(0xC0 | (cp >> 6)));
121 buf_append_char(out, (char)(0x80 | (cp & 0x3F)));
122 } else if (cp < 0x10000) {
123 buf_append_char(out, (char)(0xE0 | (cp >> 12)));
124 buf_append_char(out, (char)(0x80 | ((cp >> 6) & 0x3F)));
125 buf_append_char(out, (char)(0x80 | (cp & 0x3F)));
126 } else if (cp < 0x110000) {
127 buf_append_char(out, (char)(0xF0 | (cp >> 18)));
128 buf_append_char(out, (char)(0x80 | ((cp >> 12) & 0x3F)));
129 buf_append_char(out, (char)(0x80 | ((cp >> 6) & 0x3F)));
130 buf_append_char(out, (char)(0x80 | (cp & 0x3F)));
131 }
132 return end + 1;
133 }
134
135 // Named entities (common ones)
136 struct { const char* name; const char* value; } entities[] = {
137 {"lt", "<"}, {"gt", ">"}, {"amp", "&"}, {"quot", "\""},
138 {"apos", "'"}, {"nbsp", " "}, {"ndash", "\xe2\x80\x93"},
139 {"mdash", "\xe2\x80\x94"}, {"laquo", "\xc2\xab"},
140 {"raquo", "\xc2\xbb"}, {"copy", "\xc2\xa9"},
141 {"reg", "\xc2\xae"}, {"trade", "\xe2\x84\xa2"},
142 {"hellip", "\xe2\x80\xa6"}, {"bull", "\xe2\x80\xa2"},
143 {"rsquo", "\xe2\x80\x99"}, {"lsquo", "\xe2\x80\x98"},
144 {"rdquo", "\xe2\x80\x9d"}, {"ldquo", "\xe2\x80\x9c"},
145 {NULL, NULL}
146 };
147
148 for (int i = 0; entities[i].name; i++) {
149 if (ent_len == strlen(entities[i].name) &&
150 strncmp(name, entities[i].name, ent_len) == 0) {
151 buf_append(out, entities[i].value, strlen(entities[i].value));
152 return end + 1;
153 }
154 }
155
156 // Unknown entity - pass through
157 buf_append(out, s, end + 1);
158 return end + 1;
159}
160
161// --- Tag parsing ---
162
163typedef struct {
164 char name[64];
165 int name_len;
166 int is_closing;
167 int is_self_closing;
168 // Attributes (we parse href, src, alt, cite)
169 char href[2048];
170 char src[2048];
171 char alt[512];
172 char cite[2048];
173} Tag;
174
175// Case-insensitive compare for tag names.
176static int tag_eq(const char* a, int alen, const char* b) {
177 int blen = (int)strlen(b);
178 if (alen != blen) return 0;
179 for (int i = 0; i < alen; i++) {
180 if (tolower((unsigned char)a[i]) != tolower((unsigned char)b[i])) return 0;
181 }
182 return 1;
183}
184
185// Parse an attribute value (handles both quoted and unquoted).
186// Returns pointer past the parsed value.
187static const char* parse_attr_value(const char* p, const char* end, char* out, size_t out_size) {
188 if (p >= end) return p;
189 char quote = 0;
190 if (*p == '"' || *p == '\'') {
191 quote = *p++;
192 }
193 size_t i = 0;
194 while (p < end) {
195 if (quote) {
196 if (*p == quote) { p++; break; }
197 } else {
198 if (isspace((unsigned char)*p) || *p == '>' || *p == '/') break;
199 }
200 if (i < out_size - 1) out[i++] = *p;
201 p++;
202 }
203 out[i] = '\0';
204 return p;
205}
206
207// Parse a tag starting after '<'. Returns pointer past '>'.
208static const char* parse_tag(const char* p, const char* end, Tag* tag) {
209 memset(tag, 0, sizeof(*tag));
210
211 // Skip whitespace after '<'
212 while (p < end && isspace((unsigned char)*p)) p++;
213
214 // Check closing tag
215 if (p < end && *p == '/') {
216 tag->is_closing = 1;
217 p++;
218 }
219
220 // Parse tag name
221 while (p < end && !isspace((unsigned char)*p) && *p != '>' && *p != '/' &&
222 tag->name_len < 63) {
223 tag->name[tag->name_len++] = *p++;
224 }
225 tag->name[tag->name_len] = '\0';
226
227 // Parse attributes
228 while (p < end && *p != '>') {
229 // Skip whitespace
230 while (p < end && isspace((unsigned char)*p)) p++;
231 if (p >= end || *p == '>' || *p == '/') break;
232
233 // Parse attribute name
234 char attr_name[64] = {0};
235 int an = 0;
236 while (p < end && *p != '=' && *p != '>' && !isspace((unsigned char)*p) && an < 63) {
237 attr_name[an++] = tolower((unsigned char)*p++);
238 }
239 attr_name[an] = '\0';
240
241 // Skip '='
242 while (p < end && isspace((unsigned char)*p)) p++;
243 if (p < end && *p == '=') {
244 p++;
245 while (p < end && isspace((unsigned char)*p)) p++;
246
247 // Parse value into correct field
248 if (strcmp(attr_name, "href") == 0) {
249 p = parse_attr_value(p, end, tag->href, sizeof(tag->href));
250 } else if (strcmp(attr_name, "src") == 0) {
251 p = parse_attr_value(p, end, tag->src, sizeof(tag->src));
252 } else if (strcmp(attr_name, "alt") == 0) {
253 p = parse_attr_value(p, end, tag->alt, sizeof(tag->alt));
254 } else if (strcmp(attr_name, "cite") == 0) {
255 p = parse_attr_value(p, end, tag->cite, sizeof(tag->cite));
256 } else {
257 // Skip unknown attribute value
258 char discard[4096];
259 p = parse_attr_value(p, end, discard, sizeof(discard));
260 }
261 }
262 }
263
264 // Check self-closing and skip past '>'
265 if (p < end && *p == '/') {
266 tag->is_self_closing = 1;
267 p++;
268 }
269 if (p < end && *p == '>') p++;
270
271 return p;
272}
273
274// --- Main parser ---
275
276// Tag stack for nesting tracking.
277#define MAX_STACK 128
278
279typedef struct {
280 int in_style; // Inside <style>
281 int in_script; // Inside <script>
282 int in_pre; // Inside <pre>
283 int in_a; // Inside <a>
284 char a_href[2048]; // Current link href
285 Buffer a_text; // Current link text accumulator
286 int in_h1;
287 int in_h2;
288 Buffer h_text; // Current header text accumulator
289 int bq_depth; // Blockquote nesting depth
290 Buffer bq_text; // Current blockquote text accumulator
291 char bq_cite[2048];
292 Buffer bq_prev; // Text before blockquote (for "On...wrote:" detection)
293 int last_was_block; // Last element was a block (for spacing)
294} ParseState;
295
296HTMLConvertResult html_to_elements(const char* html, size_t len) {
297 HTMLConvertResult result;
298 result_init(&result);
299
300 if (!html || len == 0) {
301 result.ok = 1;
302 return result;
303 }
304
305 ParseState state;
306 memset(&state, 0, sizeof(state));
307 buf_init(&state.a_text);
308 buf_init(&state.h_text);
309 buf_init(&state.bq_text);
310 buf_init(&state.bq_prev);
311
312 Buffer text_buf;
313 buf_init(&text_buf);
314
315 const char* p = html;
316 const char* end = html + len;
317
318 while (p < end) {
319 if (*p == '<') {
320 // Check for comment
321 if (p + 3 < end && p[1] == '!' && p[2] == '-' && p[3] == '-') {
322 const char* ce = strstr(p + 4, "-->");
323 if (ce) { p = ce + 3; continue; }
324 p++;
325 continue;
326 }
327
328 // Check for DOCTYPE/CDATA
329 if (p + 1 < end && p[1] == '!') {
330 const char* gt = memchr(p, '>', end - p);
331 if (gt) { p = gt + 1; continue; }
332 p++;
333 continue;
334 }
335
336 Tag tag;
337 const char* after = parse_tag(p + 1, end, &tag);
338
339 // Handle specific tags
340 if (tag_eq(tag.name, tag.name_len, "style")) {
341 if (tag.is_closing) state.in_style = 0;
342 else state.in_style = 1;
343 p = after;
344 continue;
345 }
346 if (tag_eq(tag.name, tag.name_len, "script")) {
347 if (tag.is_closing) state.in_script = 0;
348 else state.in_script = 1;
349 p = after;
350 continue;
351 }
352
353 if (state.in_style || state.in_script) {
354 p = after;
355 continue;
356 }
357
358 // <br> -> newline
359 if (tag_eq(tag.name, tag.name_len, "br")) {
360 if (state.in_a) {
361 buf_append_char(&state.a_text, '\n');
362 } else if (state.in_h1 || state.in_h2) {
363 buf_append_char(&state.h_text, ' ');
364 } else if (state.bq_depth > 0) {
365 buf_append_char(&state.bq_text, '\n');
366 } else {
367 buf_append_char(&text_buf, '\n');
368 }
369 p = after;
370 continue;
371 }
372
373 // <pre>
374 if (tag_eq(tag.name, tag.name_len, "pre")) {
375 state.in_pre = !tag.is_closing;
376 p = after;
377 continue;
378 }
379
380 // <h1>
381 if (tag_eq(tag.name, tag.name_len, "h1")) {
382 if (tag.is_closing && state.in_h1) {
383 state.in_h1 = 0;
384 flush_text(&result, &text_buf);
385 HTMLElement* e = result_add(&result);
386 e->type = HELEM_H1;
387 e->text = buf_finish(&state.h_text);
388 buf_init(&state.h_text);
389 // Add block spacing
390 HTMLElement* sp = result_add(&result);
391 sp->type = HELEM_TEXT;
392 sp->text = strdup("\n\n");
393 } else if (!tag.is_closing) {
394 flush_text(&result, &text_buf);
395 state.in_h1 = 1;
396 buf_init(&state.h_text);
397 }
398 p = after;
399 continue;
400 }
401
402 // <h2>
403 if (tag_eq(tag.name, tag.name_len, "h2")) {
404 if (tag.is_closing && state.in_h2) {
405 state.in_h2 = 0;
406 flush_text(&result, &text_buf);
407 HTMLElement* e = result_add(&result);
408 e->type = HELEM_H2;
409 e->text = buf_finish(&state.h_text);
410 buf_init(&state.h_text);
411 HTMLElement* sp = result_add(&result);
412 sp->type = HELEM_TEXT;
413 sp->text = strdup("\n\n");
414 } else if (!tag.is_closing) {
415 flush_text(&result, &text_buf);
416 state.in_h2 = 1;
417 buf_init(&state.h_text);
418 }
419 p = after;
420 continue;
421 }
422
423 // <a>
424 if (tag_eq(tag.name, tag.name_len, "a")) {
425 if (tag.is_closing && state.in_a) {
426 state.in_a = 0;
427 // If inside blockquote, emit link text inline
428 if (state.bq_depth > 0) {
429 if (state.a_text.len > 0) {
430 buf_append(&state.bq_text, state.a_text.data, state.a_text.len);
431 }
432 buf_free(&state.a_text);
433 } else {
434 flush_text(&result, &text_buf);
435 HTMLElement* e = result_add(&result);
436 e->type = HELEM_LINK;
437 e->text = buf_finish(&state.a_text);
438 e->attr1 = strdup(state.a_href);
439 buf_init(&state.a_text);
440 }
441 } else if (!tag.is_closing && tag.href[0]) {
442 if (state.bq_depth == 0) flush_text(&result, &text_buf);
443 state.in_a = 1;
444 strncpy(state.a_href, tag.href, sizeof(state.a_href) - 1);
445 state.a_href[sizeof(state.a_href) - 1] = '\0';
446 buf_init(&state.a_text);
447 }
448 p = after;
449 continue;
450 }
451
452 // <img>
453 if (tag_eq(tag.name, tag.name_len, "img")) {
454 if (tag.src[0]) {
455 flush_text(&result, &text_buf);
456 HTMLElement* e = result_add(&result);
457 e->type = HELEM_IMAGE;
458 e->attr1 = strdup(tag.src);
459 e->attr2 = tag.alt[0] ? strdup(tag.alt) : strdup("Does not contain alt text");
460 }
461 p = after;
462 continue;
463 }
464
465 // <blockquote>
466 if (tag_eq(tag.name, tag.name_len, "blockquote")) {
467 if (tag.is_closing && state.bq_depth > 0) {
468 state.bq_depth--;
469 if (state.bq_depth == 0) {
470 flush_text(&result, &text_buf);
471 HTMLElement* e = result_add(&result);
472 e->type = HELEM_BLOCKQUOTE;
473 e->text = buf_finish(&state.bq_text);
474 if (tag.cite[0]) {
475 e->attr1 = strdup(tag.cite);
476 }
477 if (state.bq_prev.len > 0) {
478 e->attr2 = buf_finish(&state.bq_prev);
479 }
480 buf_init(&state.bq_text);
481 buf_init(&state.bq_prev);
482 }
483 } else if (!tag.is_closing) {
484 if (state.bq_depth == 0) {
485 // Capture preceding text for "On...wrote:" detection
486 // Look back in text_buf for the last line
487 buf_free(&state.bq_prev);
488 buf_init(&state.bq_prev);
489 if (text_buf.len > 0) {
490 // Find last non-empty line
491 int start = (int)text_buf.len - 1;
492 while (start > 0 && text_buf.data[start] == '\n') start--;
493 int line_start = start;
494 while (line_start > 0 && text_buf.data[line_start - 1] != '\n') line_start--;
495 int line_len = start - line_start + 1;
496 if (line_len > 0) {
497 buf_append(&state.bq_prev, text_buf.data + line_start, line_len);
498 }
499 }
500 flush_text(&result, &text_buf);
501 buf_init(&state.bq_text);
502 }
503 if (tag.cite[0]) {
504 strncpy(state.bq_cite, tag.cite, sizeof(state.bq_cite) - 1);
505 }
506 state.bq_depth++;
507 }
508 p = after;
509 continue;
510 }
511
512 // Block elements: add spacing
513 if (tag_eq(tag.name, tag.name_len, "p") ||
514 tag_eq(tag.name, tag.name_len, "div") ||
515 tag_eq(tag.name, tag.name_len, "li") ||
516 tag_eq(tag.name, tag.name_len, "tr") ||
517 tag_eq(tag.name, tag.name_len, "table") ||
518 tag_eq(tag.name, tag.name_len, "hr")) {
519 if (tag.is_closing || tag_eq(tag.name, tag.name_len, "hr")) {
520 if (state.bq_depth > 0) {
521 buf_append(&state.bq_text, "\n\n", 2);
522 } else {
523 buf_append(&text_buf, "\n\n", 2);
524 }
525 state.last_was_block = 1;
526 }
527 p = after;
528 continue;
529 }
530
531 // <ul>, <ol>, <dl>, <thead>, <tbody>, etc. - skip tag but process children
532 p = after;
533 continue;
534 }
535
536 // Text content
537 if (state.in_style || state.in_script) {
538 p++;
539 continue;
540 }
541
542 // Handle entities
543 if (*p == '&') {
544 Buffer* target;
545 if (state.in_a) target = &state.a_text;
546 else if (state.in_h1 || state.in_h2) target = &state.h_text;
547 else if (state.bq_depth > 0) target = &state.bq_text;
548 else target = &text_buf;
549
550 size_t consumed = decode_entity(p, end - p, target);
551 p += consumed;
552 continue;
553 }
554
555 // Regular character
556 char c = *p++;
557 if (state.in_a) {
558 buf_append_char(&state.a_text, c);
559 } else if (state.in_h1 || state.in_h2) {
560 buf_append_char(&state.h_text, c);
561 } else if (state.bq_depth > 0) {
562 buf_append_char(&state.bq_text, c);
563 } else {
564 buf_append_char(&text_buf, c);
565 }
566 }
567
568 // Flush remaining text
569 flush_text(&result, &text_buf);
570
571 // Flush any unclosed elements
572 if (state.in_h1 || state.in_h2) {
573 HTMLElement* e = result_add(&result);
574 e->type = state.in_h1 ? HELEM_H1 : HELEM_H2;
575 e->text = buf_finish(&state.h_text);
576 } else {
577 buf_free(&state.h_text);
578 }
579
580 if (state.in_a) {
581 HTMLElement* e = result_add(&result);
582 e->type = HELEM_LINK;
583 e->text = buf_finish(&state.a_text);
584 e->attr1 = strdup(state.a_href);
585 } else {
586 buf_free(&state.a_text);
587 }
588
589 if (state.bq_depth > 0) {
590 HTMLElement* e = result_add(&result);
591 e->type = HELEM_BLOCKQUOTE;
592 e->text = buf_finish(&state.bq_text);
593 if (state.bq_prev.len > 0) {
594 e->attr2 = buf_finish(&state.bq_prev);
595 }
596 } else {
597 buf_free(&state.bq_text);
598 buf_free(&state.bq_prev);
599 }
600
601 result.ok = 1;
602 return result;
603}
604
605void free_html_result(HTMLConvertResult* r) {
606 if (!r) return;
607 for (int i = 0; i < r->count; i++) {
608 free(r->elements[i].text);
609 free(r->elements[i].attr1);
610 free(r->elements[i].attr2);
611 }
612 free(r->elements);
613 r->elements = NULL;
614 r->count = 0;
615 r->cap = 0;
616}