htmlconv.c

  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}