diff --git a/src/html.rs b/src/html.rs
index 2c5ce84..afdc467 100644
--- a/src/html.rs
+++ b/src/html.rs
@@ -122,7 +122,15 @@ pub fn decode_entities(s: &str) -> String {
while let Some(i) = rest.find('&') {
out.push_str(&rest[..i]);
let tail = &rest[i..];
- let Some(end) = tail[..tail.len().min(12)].find(';') else {
+ // Entity names are ASCII, but malformed input is not necessarily so.
+ // Search the bounded byte window for the ASCII delimiter instead of
+ // slicing at byte 12, which may fall in the middle of a UTF-8 scalar.
+ let Some(end) = tail
+ .as_bytes()
+ .iter()
+ .take(12)
+ .position(|&byte| byte == b';')
+ else {
out.push('&');
rest = &tail[1..];
continue;
@@ -158,6 +166,20 @@ pub fn decode_entities(s: &str) -> String {
out
}
+/// Prefix of `s` that fits within `max_bytes` without splitting a UTF-8
+/// character.
+///
+/// This is intended for byte-budgeted logs and excerpts. The returned string
+/// may be shorter than the limit by up to three bytes.
+pub fn truncate_utf8(s: &str, max_bytes: usize) -> &str {
+ let mut end = max_bytes.min(s.len());
+ while !s.is_char_boundary(end) {
+ end -= 1;
+ }
+ // `end` is explicitly adjusted to a character boundary above.
+ s.get(..end).unwrap_or_default()
+}
+
/// Escape a string for use inside a double-quoted XML attribute.
pub fn attr_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
@@ -315,6 +337,28 @@ mod tests {
assert_eq!(decode_entities("&unknown; &"), "&unknown; &");
}
+ #[test]
+ fn entity_decoding_handles_unicode_at_the_scan_boundary() {
+ // The curly apostrophe begins at byte 11 after `&`. The bounded entity
+ // scan must leave malformed/non-entity text alone rather than slicing
+ // through the apostrophe and panicking.
+ let input = "&abcdefghij’ rest";
+ assert_eq!(decode_entities(input), input);
+
+ // A semicolon after non-ASCII text is likewise safe and remains
+ // unchanged because it is not one of the entities we decode.
+ assert_eq!(decode_entities("&é;"), "&é;");
+ }
+
+ #[test]
+ fn utf8_truncation_respects_byte_limits_and_character_boundaries() {
+ assert_eq!(truncate_utf8("abcdef", 4), "abcd");
+ assert_eq!(truncate_utf8("ab’cd", 4), "ab");
+ assert_eq!(truncate_utf8("ab’cd", 5), "ab’");
+ assert_eq!(truncate_utf8("éclair", 0), "");
+ assert_eq!(truncate_utf8("éclair", usize::MAX), "éclair");
+ }
+
#[test]
fn escaping_is_the_inverse_that_output_needs() {
assert_eq!(
diff --git a/src/images/normalize.rs b/src/images/normalize.rs
index 687d8fd..b47dc3e 100644
--- a/src/images/normalize.rs
+++ b/src/images/normalize.rs
@@ -16,7 +16,7 @@
//! Candidates are judged by *shape*, never by publisher: a string with braces,
//! whitespace or quotes in it cannot resolve, whoever wrote it.
-use crate::html::{html_to_text, parse_attrs, tag_end, tag_name};
+use crate::html::{html_to_text, parse_attrs, tag_end, tag_name, truncate_utf8};
/// Elements that readability deletes outright, and which a page may nevertheless
/// have wrapped around an image (lightbox triggers, mostly).
@@ -181,7 +181,7 @@ pub fn normalize_img_tags(html: &str) -> String {
}
out.push_str("/>");
} else {
- tracing::debug!(tag = %&raw[..raw.len().min(120)], "dropping unusable img");
+ tracing::debug!(tag = %truncate_utf8(raw, 120), "dropping unusable img");
}
}
_ => out.push_str(raw),