diff --git a/vlib/x/markdown/README.md b/vlib/x/markdown/README.md
index d570353673..6afcf99150 100644
--- a/vlib/x/markdown/README.md
+++ b/vlib/x/markdown/README.md
@@ -23,6 +23,8 @@ extensions. Designed for feature parity with
HTML output follows CommonMark block boundaries, including a newline before a
nested list or code block inside a tight list. Image alt text flattens inline
formatting, nested links, and images to their text content.
+Numeric character references follow CommonMark digit limits and replace invalid Unicode values
+with U+FFFD. URI autolinks allow empty scheme payloads and reject ASCII control characters.
### GFM Extensions (via `.gfm()` helper or individual extensions)
- **Tables**: `| col | col |` with alignment (`:--`, `:--:`, `--:`)
diff --git a/vlib/x/markdown/commonmark_inline_boundary_test.v b/vlib/x/markdown/commonmark_inline_boundary_test.v
new file mode 100644
index 0000000000..a5a6ad1d31
--- /dev/null
+++ b/vlib/x/markdown/commonmark_inline_boundary_test.v
@@ -0,0 +1,41 @@
+// Copyright 2026 The V Language. All rights reserved.
+// Use of this source code is governed by an MIT license
+// that can be found in the LICENSE file.
+module markdown
+
+fn test_numeric_entities_replace_invalid_unicode_values() {
+ for source in ['', '', '', '', '', '', '',
+ ''] {
+ assert to_html(source) == '
�
\n', source
+ }
+ assert to_html(' ') == '${rune(0x10ffff)} ${rune(0x10ffff)}
\n'
+ assert to_html('A A A') == 'A A A
\n'
+}
+
+fn test_numeric_entity_digit_limits_and_malformed_controls() {
+ for source in ['A', '', 'A', '', '', '', '-1;',
+ '+65;', 'G;', 'z;', ' 65;', 'A', 'A'] {
+ assert to_html(source) == '${source.replace('&', '&')}
\n', source
+ }
+}
+
+fn test_uri_autolinks_allow_empty_payload_and_validate_schemes() {
+ for source in ['ab:', 'http:', 'a+.-:', 'abcdefghijklmnopqrstuvwxyzabcdef:'] {
+ assert to_html('<${source}>') == '${source}
\n', source
+ }
+ for source in ['a:', 'abcdefghijklmnopqrstuvwxyzabcdefg:', '1a:', 'a_:', ':'] {
+ assert to_html('<${source}>') == '<${source}>
\n', source
+ }
+}
+
+fn test_uri_autolinks_reject_ascii_controls() {
+ for c in 0 .. 33 {
+ assert try_autolink('') == -1, c.str()
+ }
+ assert try_autolink('') == -1
+ for c in [u8(1), u8(11), u8(31), u8(127)] {
+ control := c.ascii_str()
+ assert to_html('') == '<ab:${control}payload>
\n'
+ }
+ assert to_html('') == 'ab:foo
\n'
+}
diff --git a/vlib/x/markdown/inline.v b/vlib/x/markdown/inline.v
index b5ac9265e4..ea2761f160 100644
--- a/vlib/x/markdown/inline.v
+++ b/vlib/x/markdown/inline.v
@@ -984,11 +984,11 @@ fn try_autolink(s string) int {
}
for i := 0; i < inner.len; i++ {
c := inner[i]
- if c == ` ` || c == `\t` || c == `\n` || c == `\r` || c == `<` {
+ if c <= 0x20 || c == 0x7f || c == `<` {
return -1
}
}
- // Absolute URI: scheme ':' + at least one more character.
+ // Absolute URI: scheme ':' + zero or more non-control characters.
idx := inner.index(':') or { -1 }
if idx > 0 {
scheme := inner[..idx]
@@ -1001,7 +1001,7 @@ fn try_autolink(s string) int {
break
}
}
- if ok && idx + 1 < inner.len {
+ if ok {
return end
}
}
@@ -1230,10 +1230,10 @@ fn (mut p InlineParser) try_entity() ?&Node {
return text_node(multi)
}
}
- // Out-of-range numeric references (> U+10FFFF) are not entities at all
- // (they stay literal); zero and surrogates decode to U+FFFD below.
+ // CommonMark limits the digit count, not the numeric value. Invalid
+ // Unicode values, zero, and surrogates decode to U+FFFD below.
if candidate.len > 3 && candidate[1] == `#` {
- if numeric_entity_too_large(candidate[2..candidate.len - 1]) {
+ if numeric_entity_too_long(candidate[2..candidate.len - 1]) {
return none
}
}
@@ -1251,39 +1251,13 @@ const multi_rune_entities = {
'ngE': '≧̸'
}
-// numeric_entity_too_large reports whether a numeric entity body (the part
-// between `` and `;`, with optional `x` prefix) denotes a value above
-// U+10FFFF. Malformed bodies return false (the decoder rejects them).
-fn numeric_entity_too_large(body string) bool {
- mut entity_digits := body
- mut base := 10
- if entity_digits.len > 0 && (entity_digits[0] == `x` || entity_digits[0] == `X`) {
- base = 16
- entity_digits = entity_digits[1..]
+// numeric_entity_too_long checks CommonMark's decimal and hexadecimal digit limits.
+// Malformed bodies within these limits are rejected by the entity decoder.
+fn numeric_entity_too_long(body string) bool {
+ if body.len > 0 && (body[0] == `x` || body[0] == `X`) {
+ return body.len - 1 > 6
}
- if entity_digits.len == 0 {
- return false
- }
- limit := u64(0x10ffff)
- mut v := u64(0)
- for i := 0; i < entity_digits.len; i++ {
- c := entity_digits[i]
- mut d := u64(0)
- if c >= `0` && c <= `9` {
- d = u64(c - `0`)
- } else if base == 16 && c >= `a` && c <= `f` {
- d = u64(c - `a`) + 10
- } else if base == 16 && c >= `A` && c <= `F` {
- d = u64(c - `A`) + 10
- } else {
- return false
- }
- if v > (limit - d) / u64(base) {
- return true
- }
- v = v * u64(base) + d
- }
- return v > limit
+ return body.len > 7
}
// parse_newline handles a newline character.