From 6de10465f03e7abe9fe6744936c734c7dbcfc371 Mon Sep 17 00:00:00 2001 From: krMaynard <55507135+krMaynard@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:08:10 -0700 Subject: [PATCH] fix: don't drop image-occlusion properties after an empty/backslash value parse_image_cloze parsed properties with a nom escaped/separated_pair loop that broke on the first parse failure, silently discarding the failing property and every property after it. Two reachable inputs triggered this: an empty value (e.g. an empty text label, `text=`) and a value containing a backslash not followed by `:` (e.g. LaTeX like `\frac`). In both cases the remaining occlusion properties (scale, font size, position, ...) were lost. Parse by splitting the shape off the first colon, then splitting the rest on unescaped colons and each property on its first `=`, so every property is read independently and empty/backslash values are preserved. --- rslib/src/image_occlusion/imageocclusion.rs | 86 +++++++++++++-------- 1 file changed, 55 insertions(+), 31 deletions(-) diff --git a/rslib/src/image_occlusion/imageocclusion.rs b/rslib/src/image_occlusion/imageocclusion.rs index 1de86bf8743..8d0584fb4b8 100644 --- a/rslib/src/image_occlusion/imageocclusion.rs +++ b/rslib/src/image_occlusion/imageocclusion.rs @@ -6,45 +6,54 @@ use std::fmt::Write; use anki_proto::image_occlusion::get_image_occlusion_note_response::ImageOcclusionProperty; use anki_proto::image_occlusion::get_image_occlusion_note_response::ImageOcclusionShape; use htmlescape::encode_attribute; -use nom::bytes::complete::escaped; -use nom::bytes::complete::is_not; -use nom::bytes::complete::tag; -use nom::character::complete::char; -use nom::error::ErrorKind; -use nom::sequence::preceded; -use nom::sequence::separated_pair; -use nom::Parser; fn unescape(text: &str) -> String { text.replace("\\:", ":") } -pub fn parse_image_cloze(text: &str) -> Option { - if let Some((shape, _)) = text.split_once(':') { - let mut properties = vec![]; - let mut remaining = &text[shape.len()..]; - while let Ok((rem, (name, value))) = separated_pair::<_, _, _, (_, ErrorKind), _, _, _>( - preceded(tag(":"), is_not("=")), - tag("="), - escaped(is_not("\\:"), '\\', char(':')), - ) - .parse(remaining) - { - remaining = rem; - let value = unescape(value); - properties.push(ImageOcclusionProperty { - name: name.to_string(), - value, - }) +/// Split on each `:` that is not escaped by a preceding backslash. Property +/// values escape a literal colon as `\:` (backslashes are not otherwise +/// escaped), so an unescaped `:` always separates one property from the next. +fn split_on_unescaped_colon(text: &str) -> Vec<&str> { + let mut parts = vec![]; + let mut start = 0; + let bytes = text.as_bytes(); + for i in 0..bytes.len() { + if bytes[i] == b':' { + // An even number of preceding backslashes means they pair off and + // the colon itself is unescaped (a separator); an odd number means + // the colon is escaped (`\:`) and part of the value. + let preceding_backslashes = + bytes[..i].iter().rev().take_while(|&&b| b == b'\\').count(); + if preceding_backslashes % 2 == 0 { + parts.push(&text[start..i]); + start = i + 1; + } } - - return Some(ImageOcclusionShape { - shape: shape.to_string(), - properties, - }); } + parts.push(&text[start..]); + parts +} - None +pub fn parse_image_cloze(text: &str) -> Option { + let (shape, rest) = text.split_once(':')?; + // Parse every `name=value` property. Values may be empty or contain a + // backslash (e.g. LaTeX), so we don't stop at the first one that fails a + // strict grammar - that previously discarded the rest of the properties. + let properties = split_on_unescaped_colon(rest) + .into_iter() + .filter_map(|prop| { + let (name, value) = prop.split_once('=')?; + Some(ImageOcclusionProperty { + name: name.to_string(), + value: unescape(value), + }) + }) + .collect(); + Some(ImageOcclusionShape { + shape: shape.to_string(), + properties, + }) } // convert text like @@ -167,3 +176,18 @@ fn test_get_image_cloze_data() { r#"data-shape="text" data-text="foo:bar" data-left="10" "#, ); } + +#[test] +fn parses_empty_and_backslash_values() { + // an empty intermediate value must not drop the properties that follow it + assert_eq!( + get_image_cloze_data("text:left=10:text=:top=20"), + r#"data-shape="text" data-left="10" data-top="20" "#, + ); + // a backslash in a value (e.g. LaTeX) must not drop it or the props after + // it (the backslash is HTML-escaped to \ in the data attribute) + assert_eq!( + get_image_cloze_data("text:left=10:text=\\frac:top=20"), + r#"data-shape="text" data-left="10" data-text="\frac" data-top="20" "#, + ); +}