From b927386173b179d124815e366989da07a711ab3e Mon Sep 17 00:00:00 2001 From: Bryce Kwon Date: Sat, 24 Oct 2026 09:17:41 -1000 Subject: Show characters a browser breaks at as escapes The HTML parser turns a carriage return with no line feed after it into a line feed, and WebKit starts a new line at U+2028 and U+2029, while git and the line numbers count line feeds alone, so each one set every later line of the tree and blame pages a line below its number. CSS Text makes vertical tab, form feed and next line forced breaks as well, though no engine honours that yet. The escapes follow gitweb, which shows control characters the same way. --- source/html.c | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ source/html.h | 13 +++++++++ source/ui-blame.c | 18 ++++++++++--- source/ui-tree.c | 4 +-- 4 files changed, 108 insertions(+), 6 deletions(-) (limited to 'source') diff --git a/source/html.c b/source/html.c index d1e28f8..d13ac5b 100644 --- a/source/html.c +++ b/source/html.c @@ -258,6 +258,85 @@ ssize_t html_ntxt(const char *txt, size_t len) return left; } +/* + * git ends a line only at a line feed. A browser also starts one at a carriage + * return with no line feed after it, since the HTML parser turns that into a + * line feed, and may at the other characters Unicode makes mandatory breaks. + * Escaping those keeps one line on the page for every line git counts, so the + * numbers beside the text stay in step with it. + */ +const char *html_line_break_escape(const char *p, const char *end, size_t *width) +{ + const char *esc; + + switch ((unsigned char)*p) { + case '\r': + // The parser reads CR LF as the single line feed git ends the + // line with. + if (p + 1 < end && p[1] == '\n') + return NULL; + *width = 1; + return "\\r"; + case '\v': + *width = 1; + return "\\v"; + case '\f': + *width = 1; + return "\\f"; + case 0xc2: + if (end - p < 2 || memcmp(p, "\xc2\x85", 2)) + return NULL; + *width = 2; + return "\\u0085"; + case 0xe2: + if (end - p >= 3 && !memcmp(p, "\xe2\x80\xa8", 3)) + esc = "\\u2028"; + else if (end - p >= 3 && !memcmp(p, "\xe2\x80\xa9", 3)) + esc = "\\u2029"; + else + return NULL; + *width = 3; + return esc; + default: + return NULL; + } +} + +void html_blob_txt(const char *txt, size_t len) +{ + const char *end = txt + len; + const char *p; + + for (p = txt; p < end; p++) { + const char *entity = NULL, *esc = NULL; + size_t width = 1; + + if (*p == '<') + entity = "<"; + else if (*p == '>') + entity = ">"; + else if (*p == '&') + entity = "&"; + else + esc = html_line_break_escape(p, end, &width); + if (!entity && !esc) + continue; + html_raw(txt, p - txt); + if (entity) { + html(entity); + } else { + // The span lets a stylesheet tell the escape from the + // same characters typed into the file. + html(""); + html(esc); + html(""); + } + p += width - 1; + txt = p + 1; + } + html_raw(txt, end - txt); +} + void html_attrf(const char *format, ...) { va_list args; diff --git a/source/html.h b/source/html.h index c6c0e19..9cad3a6 100644 --- a/source/html.h +++ b/source/html.h @@ -61,6 +61,19 @@ extern void html_txt(const char *txt); */ extern ssize_t html_ntxt(const char *txt, size_t len); +/* + * The escape sequence a blob's text shows for the character at p when a + * browser would start a new line at it though git does not, or NULL for any + * other character. The character's length in bytes is stored in width. + */ +extern const char *html_line_break_escape(const char *p, const char *end, size_t *width); + +/* + * Escape the text of a blob for the page beside its line numbers, writing + * each character html_line_break_escape names as its escape sequence. + */ +extern void html_blob_txt(const char *txt, size_t len); + extern void html_attr(const char *txt); extern void html_url_path(const char *txt); diff --git a/source/ui-blame.c b/source/ui-blame.c index 8fd6c83..f60020c 100644 --- a/source/ui-blame.c +++ b/source/ui-blame.c @@ -211,6 +211,15 @@ static size_t line_width(struct blame_scoreboard *sb, int line) size_t width = 0; while (pos < end) { + size_t len; + const char *esc = html_line_break_escape(pos, end, &len); + + // An escaped character takes the room of its escape sequence. + if (esc) { + width += strlen(esc); + pos += len; + continue; + } width++; if (*pos++ == '\t') width = (width + TAB_WIDTH - 1) & ~(TAB_WIDTH - 1); @@ -297,9 +306,10 @@ static void print_blame_page(const struct object_id *oid, const char *path, cgit_tree_link("tree", NULL, NULL, ctx.qry.head, rev, path); html(")\n"); - // A NUL past the window buffer_is_binary sniffs would end html_txt - // early while the hash and line number columns still cover the whole - // file, so a blob holding one anywhere is treated as binary too. + // A NUL past the window buffer_is_binary sniffs would end a Lua + // filter's output early while the hash and line number columns still + // cover the whole file, so a blob holding one anywhere is treated as + // binary too. if (buffer_is_binary(buf, size) || memchr(buf, 0, size)) { struct blame_entry *ent = sb.ent, *next; @@ -334,7 +344,7 @@ static void print_blame_page(const struct object_id *oid, const char *path, cgit_close_filter(ctx.repo->source_filter); free(filter_arg); } else { - html_txt(buf); + html_blob_txt(buf, size); } html(""); diff --git a/source/ui-tree.c b/source/ui-tree.c index 13a9787..1d8bb3d 100644 --- a/source/ui-tree.c +++ b/source/ui-tree.c @@ -113,7 +113,7 @@ static void print_text_buffer(const char *filename, char *buf, size_t size) } html("
");
-	html_txt(buf);
+	html_blob_txt(buf, size);
 	html("
\n"); } @@ -188,7 +188,7 @@ static bool print_object(const struct object_id *oid, const char *path, return false; } // buffer_is_binary only sniffs the front of the blob, and a NUL past - // that window would end the escaped text render early while the line + // that window would end a Lua filter's output early while the line // number column still counts the whole file, so check the rest too. is_binary = buffer_is_binary(buf, size) || memchr(buf, 0, size); -- cgit 2.9.1