diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 5917349..cd6d487 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -1,6 +1,12 @@ -exclude_re = ["replace > with >= in of_image"] +exclude_re = [ + "replace > with >= in of_image", + "replace - with / in Store::evict_until_under", + "replace \\| with \\^ in base64url", + "replace < with <= in rgb_of_hsl", + "replace < with <= in migrate", +] exclude_globs = [ "crates/cp-win/src/capture.rs", diff --git a/.github/workflows/mutants.yml b/.github/workflows/mutants.yml index fad75b2..aca38a2 100644 --- a/.github/workflows/mutants.yml +++ b/.github/workflows/mutants.yml @@ -10,6 +10,10 @@ on: permissions: contents: read +concurrency: + group: mutants-${{ github.ref }} + cancel-in-progress: true + env: CARGO_TERM_COLOR: always @@ -23,11 +27,17 @@ jobs: matrix: include: - os: windows-2025 - name: windows + name: windows 1 of 2 + crates: -p cp-core -p cp-store -p cp-win + shard: --shard 0/2 + - os: windows-2025 + name: windows 2 of 2 crates: -p cp-core -p cp-store -p cp-win + shard: --shard 1/2 - os: macos-15 name: macos crates: -p cp-core -p cp-store + shard: "" steps: - uses: actions/checkout@v4 with: @@ -56,7 +66,7 @@ jobs: shell: bash run: | set +e - cargo mutants ${{ matrix.crates }} --in-diff branch.diff --no-times -j 2 + cargo mutants ${{ matrix.crates }} ${{ matrix.shard }} --in-diff branch.diff --no-times -j 2 echo "code=$?" >> "$GITHUB_OUTPUT" - name: What survived @@ -70,6 +80,6 @@ jobs: - uses: actions/upload-artifact@v4 if: always() && steps.touched.outputs.any == 'yes' with: - name: mutants-branch-${{ matrix.name }} + name: mutants-branch-${{ matrix.os }}-${{ strategy.job-index }} path: mutants.out/ retention-days: 7 diff --git a/.github/workflows/rules.yml b/.github/workflows/rules.yml index 6d4ba12..2621c2d 100644 --- a/.github/workflows/rules.yml +++ b/.github/workflows/rules.yml @@ -5,6 +5,10 @@ on: branches: [main] pull_request: +concurrency: + group: rules-${{ github.ref }} + cancel-in-progress: true + jobs: conventions: name: Project conventions diff --git a/Cargo.lock b/Cargo.lock index a38cc36..f60fade 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,6 +109,7 @@ version = "3.0.0" dependencies = [ "image", "proptest", + "serde_json", "thiserror", "unicode-normalization", "xxhash-rust", @@ -205,6 +206,12 @@ dependencies = [ "objc2", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -330,13 +337,19 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hashlink" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown", + "hashbrown 0.15.5", ] [[package]] @@ -368,6 +381,22 @@ dependencies = [ "quick-error 2.0.1", ] +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "libc" version = "0.2.189" @@ -391,6 +420,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -942,6 +977,49 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "shlex" version = "2.0.1" @@ -1252,6 +1330,12 @@ version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + [[package]] name = "zune-core" version = "0.5.3" diff --git a/Cargo.toml b/Cargo.toml index cecba3b..3896f8f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ rusqlite = { version = "0.37", features = ["bundled"] } blake3 = "1" xxhash-rust = { version = "0.8", features = ["xxh3"] } unicode-normalization = "0.1" +serde_json = { version = "1", features = ["preserve_order"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "tiff", "gif", "bmp", "webp"] } proptest = "1" tempfile = "3" diff --git a/crates/cp-core/Cargo.toml b/crates/cp-core/Cargo.toml index 4f3bcbd..e0ad5f6 100644 --- a/crates/cp-core/Cargo.toml +++ b/crates/cp-core/Cargo.toml @@ -10,6 +10,7 @@ publish = false [dependencies] thiserror.workspace = true unicode-normalization.workspace = true +serde_json.workspace = true xxhash-rust.workspace = true image.workspace = true diff --git a/crates/cp-core/src/capture.rs b/crates/cp-core/src/capture.rs new file mode 100644 index 0000000..5f9010c --- /dev/null +++ b/crates/cp-core/src/capture.rs @@ -0,0 +1,139 @@ +use crate::formats::Refusal; +use crate::item::Item; +use crate::watch::{Retried, Retry, insist}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Captured { + Kept(Item), + Refused(Refusal), + Nothing, + TooSlow, + Superseded, +} + +impl Captured { + pub fn kept(self) -> Option { + match self { + Captured::Kept(item) => Some(item), + _ => None, + } + } +} + +pub fn insisting( + retry: Retry, + count: impl Fn() -> i64, + mut once: impl FnMut() -> Captured, +) -> Captured { + let attempt = || match once() { + Captured::TooSlow => None, + other => Some(other), + }; + match insist(retry, count, attempt, std::thread::sleep) { + Retried::Done(captured) => captured, + Retried::Superseded => Captured::Superseded, + Retried::Exhausted => Captured::TooSlow, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn quick() -> Retry { + Retry { + attempts: 3, + pause: std::time::Duration::from_millis(1), + } + } + + #[test] + fn a_slow_source_that_answers_on_the_second_try_is_kept() { + let mut tries = 0; + let got = insisting( + quick(), + || 7, + || { + tries += 1; + if tries < 2 { + Captured::TooSlow + } else { + Captured::Kept(Item::plain("tarde pero llega")) + } + }, + ); + assert_eq!(got, Captured::Kept(Item::plain("tarde pero llega"))); + } + + #[test] + fn a_source_that_stays_slow_is_too_slow_in_the_end() { + let mut tries = 0; + let got = insisting( + quick(), + || 7, + || { + tries += 1; + Captured::TooSlow + }, + ); + assert_eq!(got, Captured::TooSlow); + assert_eq!(tries, 3); + } + + #[test] + fn an_empty_clipboard_is_an_answer_not_a_delay() { + let mut tries = 0; + let got = insisting( + quick(), + || 7, + || { + tries += 1; + Captured::Nothing + }, + ); + assert_eq!(got, Captured::Nothing); + assert_eq!(tries, 1); + } + + #[test] + fn a_refusal_is_final_on_the_first_try() { + let mut tries = 0; + let got = insisting( + quick(), + || 7, + || { + tries += 1; + Captured::Refused(Refusal::Marked("org.nspasteboard.ConcealedType")) + }, + ); + assert_eq!( + got, + Captured::Refused(Refusal::Marked("org.nspasteboard.ConcealedType")) + ); + assert_eq!(tries, 1); + } + + #[test] + fn a_copy_made_while_insisting_supersedes_the_slow_one() { + let count = std::cell::Cell::new(7); + let got = insisting( + quick(), + || count.get(), + || { + count.set(8); + Captured::TooSlow + }, + ); + assert_eq!(got, Captured::Superseded); + } + + #[test] + fn only_what_was_kept_is_an_item() { + let item = Item::plain("hola"); + assert_eq!(Captured::Kept(item.clone()).kept(), Some(item)); + assert_eq!(Captured::Nothing.kept(), None); + assert_eq!(Captured::TooSlow.kept(), None); + assert_eq!(Captured::Superseded.kept(), None); + assert_eq!(Captured::Refused(Refusal::Declined("x")).kept(), None); + } +} diff --git a/crates/cp-core/src/formats.rs b/crates/cp-core/src/formats.rs index fe736b4..999217e 100644 --- a/crates/cp-core/src/formats.rs +++ b/crates/cp-core/src/formats.rs @@ -39,6 +39,7 @@ pub struct Catalog { pub images_by_preference: &'static [&'static str], pub equivalents: &'static [&'static [&'static str]], pub embeddable: &'static [&'static str], + pub page_archives: &'static [&'static str], } impl Catalog { @@ -68,6 +69,9 @@ impl Catalog { if family == Some(Family::Files) && self.images_by_preference.contains(&id) { return Take::Presence; } + if family == Some(Family::Image) && self.page_archives.contains(&id) { + return Take::Presence; + } self.decide(id) } @@ -148,6 +152,7 @@ mod tests { images_by_preference: &["cheap/image", "costly/image"], equivalents: &[&["cheap/markup", "costly/markup"]], embeddable: &["embedded/document"], + page_archives: &["whole/page"], }; const NOTHING: Catalog = Catalog { @@ -163,6 +168,7 @@ mod tests { images_by_preference: &[], equivalents: &[], embeddable: &[], + page_archives: &[], }; #[test] @@ -217,6 +223,7 @@ mod tests { images_by_preference: &["costly/image"], equivalents: &[], embeddable: &[], + page_archives: &[], }; assert_eq!( GREEDY.decide("costly/image"), @@ -308,6 +315,29 @@ mod tests { } } + #[test] + fn a_copied_image_notes_the_page_archive_and_a_text_keeps_it() { + const ARCHIVING: Catalog = Catalog { + wanted: &["plain/text", "cheap/image", "whole/page"], + ..PROBE + }; + assert_eq!( + ARCHIVING.decide_in(Some(Family::Image), "whole/page"), + Take::Presence, + "un logo de 43 KB no puede costar la página entera" + ); + assert_eq!( + ARCHIVING.decide_in(Some(Family::Text), "whole/page"), + Take::Payload, + "para un texto con estilos el archivo es el contenido" + ); + assert_eq!(ARCHIVING.decide_in(None, "whole/page"), Take::Payload); + assert_eq!( + ARCHIVING.decide_in(Some(Family::Image), "cheap/image"), + Take::Payload + ); + } + #[test] fn a_file_copy_notes_the_icon_under_its_legacy_name_too() { const ICONIC: Catalog = Catalog { diff --git a/crates/cp-core/src/identity.rs b/crates/cp-core/src/identity.rs new file mode 100644 index 0000000..038b26a --- /dev/null +++ b/crates/cp-core/src/identity.rs @@ -0,0 +1,160 @@ +use std::borrow::Cow; + +const VOLATILE: &[&[u8]] = &[b"docs-internal-guid-"]; + +const RENDERINGS: &[&str] = &[ + "public.rtf", + "com.apple.flat-rtfd", + "com.apple.webarchive", + "com.adobe.pdf", + "text/rtf", + "application/pdf", + "rich text format", +]; + +pub fn bears_identity(id: &str) -> bool { + !RENDERINGS.contains(&id.to_ascii_lowercase().as_str()) +} + +pub fn is_markup(id: &str) -> bool { + id.to_ascii_lowercase().contains("html") +} + +pub fn stable<'a>(id: &str, bytes: &'a [u8]) -> Cow<'a, [u8]> { + if !is_markup(id) { + return Cow::Borrowed(bytes); + } + let Some((first, marker)) = next_volatile(bytes) else { + return Cow::Borrowed(bytes); + }; + let mut out = Vec::with_capacity(bytes.len()); + let mut at = 0; + let mut found = Some((first, marker)); + while let Some((start, marker)) = found { + let end = at + start + marker.len(); + out.extend_from_slice(&bytes[at..end]); + at = end; + while at < bytes.len() && (bytes[at].is_ascii_hexdigit() || bytes[at] == b'-') { + at += 1; + } + found = next_volatile(&bytes[at..]); + } + out.extend_from_slice(&bytes[at..]); + Cow::Owned(out) +} + +fn next_volatile(bytes: &[u8]) -> Option<(usize, &'static [u8])> { + VOLATILE + .iter() + .filter_map(|marker| find(bytes, marker).map(|at| (at, *marker))) + .min_by_key(|(at, _)| *at) +} + +fn find(bytes: &[u8], marker: &[u8]) -> Option { + bytes + .windows(marker.len()) + .position(|window| window == marker) +} + +#[cfg(test)] +mod tests { + use super::*; + + const DOCS: &str = "hola"; + + #[test] + fn the_guid_google_writes_on_every_copy_leaves_the_identity() { + let again = DOCS.replace( + "4a1e6b2f-7fff-1d3e-8c5a-2b3c4d5e6f70", + "0c9d8e7f-7fff-aaaa-bbbb-000000000001", + ); + assert_ne!(DOCS, again); + assert_eq!( + stable("public.html", DOCS.as_bytes()), + stable("public.html", again.as_bytes()) + ); + assert_eq!( + stable("HTML Format", DOCS.as_bytes()), + stable("HTML Format", again.as_bytes()), + "en Windows el mismo HTML llega bajo otro nombre" + ); + let kept = stable("public.html", DOCS.as_bytes()); + assert!( + std::str::from_utf8(&kept) + .unwrap() + .contains("id=\"docs-internal-guid-\">hola"), + "queda el marcador sin su parte variable, y el resto intacto" + ); + } + + #[test] + fn markup_without_anything_volatile_is_borrowed_not_copied() { + let html = b"hola"; + assert!(matches!(stable("public.html", html), Cow::Borrowed(_))); + assert_eq!(stable("public.html", html).as_ref(), html); + } + + #[test] + fn only_markup_is_looked_at() { + let text = b"pega docs-internal-guid-1234 tal cual"; + assert!(matches!( + stable("public.utf8-plain-text", text), + Cow::Borrowed(_) + )); + assert!(matches!(stable("public.png", text), Cow::Borrowed(_))); + assert!(matches!(stable("public.html", text), Cow::Owned(_))); + assert!(matches!(stable("text/html", text), Cow::Owned(_))); + assert!(is_markup("HTML Format")); + assert!(!is_markup("public.rtf")); + assert!(!is_markup("com.apple.webarchive")); + } + + #[test] + fn a_rendering_of_the_content_does_not_carry_its_identity() { + for id in [ + "public.rtf", + "com.apple.flat-rtfd", + "com.apple.webarchive", + "com.adobe.pdf", + "Rich Text Format", + "text/rtf", + ] { + assert!(!bears_identity(id), "{id}"); + } + for id in [ + "public.utf8-plain-text", + "public.html", + "HTML Format", + "public.png", + "public.tiff", + "public.file-url", + "text/plain", + "image/png", + ] { + assert!(bears_identity(id), "{id}"); + } + } + + #[test] + fn every_occurrence_goes_and_what_follows_the_guid_stays() { + let html = + b"xyz"; + let kept = stable("public.html", html); + assert_eq!( + kept.as_ref(), + b"xyz" + ); + let at_the_end = b""; + assert_eq!( + stable("public.html", upper).as_ref(), + b"id=\"docs-internal-guid-\">", + "el GUID puede venir en mayúsculas" + ); + } +} diff --git a/crates/cp-core/src/item.rs b/crates/cp-core/src/item.rs index fc7d706..d808170 100644 --- a/crates/cp-core/src/item.rs +++ b/crates/cp-core/src/item.rs @@ -63,6 +63,8 @@ pub const SYNTHETIC_TEXT: &str = "text/plain"; pub const SYNTHETIC_IMAGE: &str = "image/png"; +pub const SYNTHETIC_JPEG: &str = "image/jpeg"; + impl Item { pub fn plain(text: &str) -> Self { Self { @@ -75,19 +77,29 @@ impl Item { } pub fn fingerprint(&self) -> u64 { - let mut mixed: Vec = Vec::new(); - let mut ordered: Vec<&Format> = self.formats.iter().collect(); - ordered.sort_by(|a, b| a.id.cmp(&b.id)); - for format in ordered { - match &format.payload { + let mut with_bytes: Vec<(&str, &[u8])> = self + .formats + .iter() + .filter_map(|format| match &format.payload { Payload::Inline(bytes) | Payload::Blob(bytes) => { - mixed.extend_from_slice(format.id.as_bytes()); - mixed.push(0); - mixed.extend_from_slice(bytes); - mixed.push(0); + Some((format.id.as_str(), bytes.as_slice())) } - _ => {} - } + _ => None, + }) + .collect(); + if with_bytes + .iter() + .any(|(id, _)| crate::identity::bears_identity(id)) + { + with_bytes.retain(|(id, _)| crate::identity::bears_identity(id)); + } + with_bytes.sort_by(|a, b| a.0.cmp(b.0)); + let mut mixed: Vec = Vec::new(); + for (id, bytes) in with_bytes { + mixed.extend_from_slice(id.as_bytes()); + mixed.push(0); + mixed.extend_from_slice(&crate::identity::stable(id, bytes)); + mixed.push(0); } crate::hash::content_hash(&mixed) } @@ -216,6 +228,111 @@ mod tests { assert_eq!(one.fingerprint(), other.fingerprint()); } + #[test] + fn two_copies_of_the_same_google_document_are_one_item() { + let docs = |guid: &str| { + Item { + kind: Some(crate::kind::Kind::Text), + formats: vec![ + Format { + id: "public.html".into(), + payload: Payload::Inline( + format!("hola") + .into_bytes(), + ), + }, + Format { + id: "public.utf8-plain-text".into(), + payload: Payload::Inline(b"hola".to_vec()), + }, + ], + } + }; + let first = docs("4a1e6b2f-7fff-1d3e-8c5a-2b3c4d5e6f70"); + let second = docs("0c9d8e7f-7fff-aaaa-bbbb-000000000001"); + assert_ne!(first, second, "los bytes sí difieren"); + assert_eq!(first.fingerprint(), second.fingerprint()); + assert_ne!( + first.fingerprint(), + Item::plain("hola").fingerprint(), + "el texto plano copiado de donde se pegó sigue siendo otro ítem" + ); + let other_words = docs("4a1e6b2f-7fff-1d3e-8c5a-2b3c4d5e6f70"); + let mut changed = other_words.clone(); + changed.formats[1].payload = Payload::Inline(b"adios".to_vec()); + assert_ne!(other_words.fingerprint(), changed.fingerprint()); + } + + #[test] + fn two_copies_of_the_same_word_paragraph_are_one_item() { + let word = |rsid: &str, base: &str, stamp: u8| Item { + kind: Some(crate::kind::Kind::Text), + formats: vec![ + Format { + id: "public.rtf".into(), + payload: Payload::Inline( + format!("{{\\rtf1{{\\*\\rsidtbl \\rsid{rsid}}}\\insrsid{rsid} hola}}") + .into_bytes(), + ), + }, + Format { + id: "public.utf8-plain-text".into(), + payload: Payload::Inline(b"hola".to_vec()), + }, + Format { + id: "public.html".into(), + payload: Payload::Inline(b"

hola

".to_vec()), + }, + Format { + id: "com.apple.webarchive".into(), + payload: Payload::Inline(format!("applewebdata://{base}").into_bytes()), + }, + Format { + id: "com.apple.flat-rtfd".into(), + payload: Payload::Inline(vec![b'r', b't', b'f', b'd', stamp]), + }, + ], + }; + let first = word("11014195", "BD20AAE0-5062", 1); + let second = word("15277860", "85F1BD98-1A2D", 2); + assert_ne!(first, second); + assert_eq!( + first.fingerprint(), + second.fingerprint(), + "los rsid, el UUID del webarchive y la fecha del RTFD no son contenido" + ); + let mut other_words = second.clone(); + other_words.formats[2].payload = Payload::Inline(b"

adios

".to_vec()); + assert_ne!(first.fingerprint(), other_words.fingerprint()); + } + + #[test] + fn a_copy_that_is_only_a_rendering_still_has_an_identity_of_its_own() { + let rtf = |body: &str| Item { + kind: Some(crate::kind::Kind::Text), + formats: vec![Format { + id: "public.rtf".into(), + payload: Payload::Inline(body.as_bytes().to_vec()), + }], + }; + assert_eq!( + rtf("{\\rtf1 a}").fingerprint(), + rtf("{\\rtf1 a}").fingerprint() + ); + assert_ne!( + rtf("{\\rtf1 a}").fingerprint(), + rtf("{\\rtf1 b}").fingerprint() + ); + assert_ne!( + rtf("{\\rtf1 a}").fingerprint(), + Item { + kind: None, + formats: vec![] + } + .fingerprint() + ); + } + #[test] fn what_was_only_announced_does_not_change_the_identity() { let with_note = Item { diff --git a/crates/cp-core/src/kind.rs b/crates/cp-core/src/kind.rs index 3dacfea..c033c1c 100644 --- a/crates/cp-core/src/kind.rs +++ b/crates/cp-core/src/kind.rs @@ -14,9 +14,32 @@ pub enum Kind { Folder, Audio, Video, + Token, } impl Kind { + pub const ALL: [Kind; 15] = [ + Kind::Text, + Kind::Code, + Kind::Json, + Kind::Link, + Kind::Email, + Kind::Phone, + Kind::Color, + Kind::Ip, + Kind::Uuid, + Kind::Image, + Kind::File, + Kind::Folder, + Kind::Audio, + Kind::Video, + Kind::Token, + ]; + + pub fn from_name(name: &str) -> Option { + Kind::ALL.into_iter().find(|kind| kind.as_str() == name) + } + pub fn as_str(self) -> &'static str { match self { Kind::Text => "text", @@ -33,6 +56,7 @@ impl Kind { Kind::Folder => "folder", Kind::Audio => "audio", Kind::Video => "video", + Kind::Token => "token", } } } @@ -44,6 +68,9 @@ pub fn classify_text(content: &str) -> Kind { } if !text.contains('\n') { + if crate::token::looks_like(text) { + return Kind::Token; + } if is_email(text) { return Kind::Email; } @@ -276,6 +303,24 @@ fn looks_like_code(text: &str) -> bool { mod tests { use super::*; + #[test] + fn every_class_goes_to_its_name_and_back() { + for kind in Kind::ALL { + assert_eq!(Kind::from_name(kind.as_str()), Some(kind)); + } + let mut names: Vec<&str> = Kind::ALL.iter().map(|kind| kind.as_str()).collect(); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), 15, "quince clases, quince nombres distintos"); + } + + #[test] + fn a_name_that_is_not_a_class_is_nobody() { + assert_eq!(Kind::from_name("Text"), None, "el nombre es exacto"); + assert_eq!(Kind::from_name("imagen"), None); + assert_eq!(Kind::from_name(""), None); + } + #[test] fn the_classes_2x_already_recognised() { assert_eq!(classify_text("alguien@ejemplo.test"), Kind::Email); @@ -291,6 +336,20 @@ mod tests { assert_eq!(classify_text(r#"{"a": 1}"#), Kind::Json); } + #[test] + fn a_token_is_its_own_class_and_wins_over_the_rest() { + assert_eq!( + classify_text("ghp_not_a_real_token_for_tests_0000000000"), + Kind::Token + ); + assert_eq!( + classify_text(" sk-not-a-real-key-for-tests-0123456789\n"), + Kind::Token, + "recortado, como todo lo demás" + ); + assert_eq!(classify_text("sk-corto"), Kind::Text); + } + #[test] fn the_classes_2x_had_a_type_for_but_never_assigned() { assert_eq!(classify_text("https://ejemplo.test/ruta?q=1"), Kind::Link); diff --git a/crates/cp-core/src/lib.rs b/crates/cp-core/src/lib.rs index d65efb4..d4fa476 100644 --- a/crates/cp-core/src/lib.rs +++ b/crates/cp-core/src/lib.rs @@ -1,13 +1,18 @@ +pub mod capture; pub mod destination; pub mod dib; pub mod formats; pub mod hash; +pub mod identity; pub mod item; pub mod kind; pub mod paste; +pub mod paste_as; pub mod search; pub mod thumbnail; +pub mod token; pub mod watch; +pub use capture::Captured; pub use formats::Family; pub use kind::Kind; diff --git a/crates/cp-core/src/paste_as.rs b/crates/cp-core/src/paste_as.rs new file mode 100644 index 0000000..cb63d5f --- /dev/null +++ b/crates/cp-core/src/paste_as.rs @@ -0,0 +1,2442 @@ +use crate::kind::Kind; +use serde_json::Value; +use std::borrow::Cow; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Content<'a> { + pub kind: Option, + pub text: Option>, + pub html: Option>, + pub rich: bool, + pub image: Option<&'a [u8]>, + pub paths: Vec, + pub title: Option>, + pub ocr: Option<&'a str>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Form { + PlainText, + Markdown, + JsonPretty, + JsonMinified, + JsonKeys, + JsonTable, + ColorHex, + ColorRgb, + ColorHsl, + ColorName, + LinkMarkdown, + LinkDomain, + LinkTitled, + CodeOneLine, + CodeBlock, + CodeDedented, + TokenHeader, + TokenClaims, + TokenCurl, + ImageJpeg, + ImageOcr, + Path, +} + +impl Form { + pub const ALL: [Form; 22] = [ + Form::PlainText, + Form::Markdown, + Form::JsonPretty, + Form::JsonMinified, + Form::JsonKeys, + Form::JsonTable, + Form::ColorHex, + Form::ColorRgb, + Form::ColorHsl, + Form::ColorName, + Form::LinkMarkdown, + Form::LinkDomain, + Form::LinkTitled, + Form::CodeOneLine, + Form::CodeBlock, + Form::CodeDedented, + Form::TokenHeader, + Form::TokenClaims, + Form::TokenCurl, + Form::ImageJpeg, + Form::ImageOcr, + Form::Path, + ]; + + pub fn as_str(self) -> &'static str { + match self { + Form::PlainText => "plain-text", + Form::Markdown => "markdown", + Form::JsonPretty => "json-pretty", + Form::JsonMinified => "json-minified", + Form::JsonKeys => "json-keys", + Form::JsonTable => "json-table", + Form::ColorHex => "color-hex", + Form::ColorRgb => "color-rgb", + Form::ColorHsl => "color-hsl", + Form::ColorName => "color-name", + Form::LinkMarkdown => "link-markdown", + Form::LinkDomain => "link-domain", + Form::LinkTitled => "link-titled", + Form::CodeOneLine => "code-one-line", + Form::CodeBlock => "code-block", + Form::CodeDedented => "code-dedented", + Form::TokenHeader => "token-header", + Form::TokenClaims => "token-claims", + Form::TokenCurl => "token-curl", + Form::ImageJpeg => "image-jpeg", + Form::ImageOcr => "image-ocr", + Form::Path => "path", + } + } + + pub fn from_name(name: &str) -> Option
{ + Form::ALL.into_iter().find(|form| form.as_str() == name) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Rendered { + Text(String), + Jpeg(Vec), +} + +impl Rendered { + pub fn into_item(self) -> crate::item::Item { + use crate::item::{Format, Item, Payload, SYNTHETIC_JPEG}; + match self { + Rendered::Text(text) => Item::plain(&text), + Rendered::Jpeg(bytes) => Item { + kind: Some(Kind::Image), + formats: vec![Format { + id: SYNTHETIC_JPEG.into(), + payload: Payload::stored(bytes), + }], + }, + } + } +} + +pub const JPEG_QUALITY: u8 = 85; + +pub fn forms_for(content: &Content) -> Vec { + let mut forms = Vec::new(); + if content.rich && content.text.is_some() { + forms.push(Form::PlainText); + } + if content.html.is_some() { + forms.push(Form::Markdown); + } + let text = content.text.as_deref().map(str::trim).unwrap_or_default(); + match content.kind { + Some(Kind::Json) => { + if let Ok(value) = serde_json::from_str::(text) { + forms.extend([Form::JsonPretty, Form::JsonMinified]); + if json_keys(&value).is_some() { + forms.push(Form::JsonKeys); + } + if json_table(&value).is_some() { + forms.push(Form::JsonTable); + } + } + } + Some(Kind::Color) => { + if let Some(color) = parse_color(text) { + forms.extend([Form::ColorHex, Form::ColorRgb, Form::ColorHsl]); + if named_color(color).is_some() { + forms.push(Form::ColorName); + } + } + } + Some(Kind::Link) => { + forms.push(Form::LinkMarkdown); + if domain_of(text).is_some() { + forms.push(Form::LinkDomain); + } + if title_of(content).is_some() { + forms.push(Form::LinkTitled); + } + } + Some(Kind::Code) => { + forms.extend([Form::CodeOneLine, Form::CodeBlock, Form::CodeDedented]); + } + Some(Kind::Token) => { + forms.extend([Form::TokenHeader, Form::TokenCurl]); + if crate::token::claims_of(text).is_some() { + forms.push(Form::TokenClaims); + } + } + _ => {} + } + if content.image.is_some() { + forms.push(Form::ImageJpeg); + if content.ocr.is_some_and(|ocr| !ocr.trim().is_empty()) { + forms.push(Form::ImageOcr); + } + } + if !content.paths.is_empty() { + forms.push(Form::Path); + } + forms +} + +pub fn render(form: Form, content: &Content) -> Option { + let text = content.text.as_deref().map(str::trim).unwrap_or_default(); + let rendered = match form { + Form::PlainText => content.text.as_deref()?.to_owned(), + Form::Markdown => markdown_of_html(content.html.as_deref()?), + Form::JsonPretty => serde_json::to_string_pretty(&json_of(text)?).ok()?, + Form::JsonMinified => serde_json::to_string(&json_of(text)?).ok()?, + Form::JsonKeys => json_keys(&json_of(text)?)?, + Form::JsonTable => json_table(&json_of(text)?)?, + Form::ColorHex => hex_of(parse_color(text)?), + Form::ColorRgb => rgb_of(parse_color(text)?), + Form::ColorHsl => hsl_of(parse_color(text)?), + Form::ColorName => named_color(parse_color(text)?)?.to_owned(), + Form::LinkMarkdown => markdown_link(title_of(content).unwrap_or(text), text), + Form::LinkDomain => lowercase_domain(text)?, + Form::LinkTitled => format!("{} — {text}", title_of(content)?), + Form::CodeOneLine => one_line(text), + Form::CodeBlock => fenced(text), + Form::CodeDedented => dedent(content.text.as_deref()?), + Form::TokenHeader => format!("Authorization: Bearer {text}"), + Form::TokenClaims => { + let claims = crate::token::claims_of(text)?; + serde_json::to_string_pretty(&Value::Object(claims.payload)).ok()? + } + Form::TokenCurl => format!( + "curl -H 'Authorization: Bearer {}' \"$URL\"", + text.replace('\'', "'\\''") + ), + Form::ImageJpeg => return jpeg_of(content.image?).map(Rendered::Jpeg), + Form::ImageOcr => content.ocr?.trim().to_owned(), + Form::Path => content.paths.join("\n"), + }; + Some(Rendered::Text(rendered)) +} + +fn title_of<'a>(content: &'a Content<'_>) -> Option<&'a str> { + content + .title + .as_deref() + .map(str::trim) + .filter(|title| !title.is_empty()) +} + +fn markdown_link(label: &str, url: &str) -> String { + let label = label.replace(['[', ']'], " "); + if url.contains([' ', '(', ')', '<', '>']) { + format!("[{}](<{}>)", label.trim(), url.replace(['<', '>'], "")) + } else { + format!("[{}]({url})", label.trim()) + } +} + +fn fenced(text: &str) -> String { + let longest = text.split(|c| c != '`').map(str::len).max().unwrap_or(0); + let fence = "`".repeat(longest.max(2) + 1); + format!("{fence}\n{text}\n{fence}") +} + +fn json_of(text: &str) -> Option { + serde_json::from_str(text).ok() +} + +fn json_keys(value: &Value) -> Option { + let keys: Vec<&str> = match value { + Value::Object(map) => map.keys().map(String::as_str).collect(), + Value::Array(rows) => { + let mut seen = Vec::new(); + for row in rows { + let Value::Object(map) = row else { + return None; + }; + for key in map.keys() { + if !seen.contains(&key.as_str()) { + seen.push(key.as_str()); + } + } + } + seen + } + _ => return None, + }; + (!keys.is_empty()).then(|| keys.join("\n")) +} + +fn json_table(value: &Value) -> Option { + match value { + Value::Object(map) => { + let rows: Vec = map + .iter() + .map(|(key, value)| format!("{key}\t{}", cell(value))) + .collect(); + (!rows.is_empty()).then(|| rows.join("\n")) + } + Value::Array(rows) => { + let header = json_keys(value)?; + let columns: Vec<&str> = header.lines().collect(); + let mut lines = vec![columns.join("\t")]; + for row in rows { + let Value::Object(map) = row else { + return None; + }; + let cells: Vec = columns + .iter() + .map(|column| map.get(*column).map(cell).unwrap_or_default()) + .collect(); + lines.push(cells.join("\t")); + } + Some(lines.join("\n")) + } + _ => None, + } +} + +fn cell(value: &Value) -> String { + match value { + Value::String(text) => text.replace(['\t', '\n'], " "), + Value::Null => String::new(), + other => other.to_string(), + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Rgba { + pub r: u8, + pub g: u8, + pub b: u8, + pub a: f64, +} + +pub fn parse_color(text: &str) -> Option { + if let Some(hex) = text.strip_prefix('#') { + let digits: Vec = hex + .chars() + .map(|c| c.to_digit(16).map(|d| d as u8)) + .collect::>()?; + let (r, g, b, a) = match digits.as_slice() { + [r, g, b] => (r * 17, g * 17, b * 17, 255), + [r, r2, g, g2, b, b2] => (r * 16 + r2, g * 16 + g2, b * 16 + b2, 255), + [r, r2, g, g2, b, b2, a, a2] => (r * 16 + r2, g * 16 + g2, b * 16 + b2, a * 16 + a2), + _ => return None, + }; + return Some(Rgba { + r, + g, + b, + a: f64::from(a) / 255.0, + }); + } + let lower = text.to_ascii_lowercase(); + let (kind, inner) = lower + .split_once('(') + .and_then(|(kind, rest)| Some((kind, rest.strip_suffix(')')?)))?; + let parts: Vec<(f64, bool)> = inner + .split(',') + .map(|part| { + let part = part.trim(); + let percent = part.ends_with('%'); + part.trim_end_matches('%') + .parse::() + .ok() + .map(|value| (value, percent)) + }) + .collect::>()?; + let alpha = |value: Option<&(f64, bool)>| match value { + Some((value, true)) => (value / 100.0).clamp(0.0, 1.0), + Some((value, false)) => value.clamp(0.0, 1.0), + None => 1.0, + }; + let byte = |(value, percent): (f64, bool)| { + channel(if percent { + value * 255.0 / 100.0 + } else { + value + }) + }; + match (kind, parts.as_slice()) { + ("rgb", [r, g, b]) | ("rgba", [r, g, b, _]) => Some(Rgba { + r: byte(*r), + g: byte(*g), + b: byte(*b), + a: alpha(parts.get(3)), + }), + ("hsl", [(h, _), (s, _), (l, _)]) | ("hsla", [(h, _), (s, _), (l, _), _]) => { + let (r, g, b) = rgb_of_hsl(*h, s / 100.0, l / 100.0); + Some(Rgba { + r, + g, + b, + a: alpha(parts.get(3)), + }) + } + _ => None, + } +} + +fn channel(value: f64) -> u8 { + value.round().clamp(0.0, 255.0) as u8 +} + +fn rgb_of_hsl(h: f64, s: f64, l: f64) -> (u8, u8, u8) { + let h = h.rem_euclid(360.0) / 360.0; + let (s, l) = (s.clamp(0.0, 1.0), l.clamp(0.0, 1.0)); + let q = if l < 0.5 { + l * (1.0 + s) + } else { + l + s - l * s + }; + let p = 2.0 * l - q; + let hue = |mut t: f64| { + t = t.rem_euclid(1.0); + let value = if t < 1.0 / 6.0 { + p + (q - p) * 6.0 * t + } else if t < 0.5 { + q + } else if t < 2.0 / 3.0 { + p + (q - p) * (2.0 / 3.0 - t) * 6.0 + } else { + p + }; + channel(value * 255.0) + }; + (hue(h + 1.0 / 3.0), hue(h), hue(h - 1.0 / 3.0)) +} + +pub const NAME_WITHIN: u32 = 30; + +pub fn named_color(color: Rgba) -> Option<&'static str> { + if color.a < 1.0 { + return None; + } + let (name, apart) = NAMED + .iter() + .map(|(name, rgb)| (*name, redmean_squared([color.r, color.g, color.b], *rgb))) + .min_by_key(|(_, apart)| *apart)?; + (apart <= 512 * NAME_WITHIN * NAME_WITHIN).then_some(name) +} + +fn redmean_squared(a: [u8; 3], b: [u8; 3]) -> u32 { + let reds = u32::from(a[0]) + u32::from(b[0]); + let delta = |i: usize| { + let d = i32::from(a[i]) - i32::from(b[i]); + (d * d) as u32 + }; + (1024 + reds) * delta(0) + 2048 * delta(1) + (1024 + 510 - reds) * delta(2) +} + +const NAMED: [(&str, [u8; 3]); 139] = [ + ("aliceblue", [0xF0, 0xF8, 0xFF]), + ("antiquewhite", [0xFA, 0xEB, 0xD7]), + ("aquamarine", [0x7F, 0xFF, 0xD4]), + ("azure", [0xF0, 0xFF, 0xFF]), + ("beige", [0xF5, 0xF5, 0xDC]), + ("bisque", [0xFF, 0xE4, 0xC4]), + ("black", [0x00, 0x00, 0x00]), + ("blanchedalmond", [0xFF, 0xEB, 0xCD]), + ("blue", [0x00, 0x00, 0xFF]), + ("blueviolet", [0x8A, 0x2B, 0xE2]), + ("brown", [0xA5, 0x2A, 0x2A]), + ("burlywood", [0xDE, 0xB8, 0x87]), + ("cadetblue", [0x5F, 0x9E, 0xA0]), + ("chartreuse", [0x7F, 0xFF, 0x00]), + ("chocolate", [0xD2, 0x69, 0x1E]), + ("coral", [0xFF, 0x7F, 0x50]), + ("cornflowerblue", [0x64, 0x95, 0xED]), + ("cornsilk", [0xFF, 0xF8, 0xDC]), + ("crimson", [0xDC, 0x14, 0x3C]), + ("cyan", [0x00, 0xFF, 0xFF]), + ("darkblue", [0x00, 0x00, 0x8B]), + ("darkcyan", [0x00, 0x8B, 0x8B]), + ("darkgoldenrod", [0xB8, 0x86, 0x0B]), + ("darkgray", [0xA9, 0xA9, 0xA9]), + ("darkgreen", [0x00, 0x64, 0x00]), + ("darkkhaki", [0xBD, 0xB7, 0x6B]), + ("darkmagenta", [0x8B, 0x00, 0x8B]), + ("darkolivegreen", [0x55, 0x6B, 0x2F]), + ("darkorange", [0xFF, 0x8C, 0x00]), + ("darkorchid", [0x99, 0x32, 0xCC]), + ("darkred", [0x8B, 0x00, 0x00]), + ("darksalmon", [0xE9, 0x96, 0x7A]), + ("darkseagreen", [0x8F, 0xBC, 0x8F]), + ("darkslateblue", [0x48, 0x3D, 0x8B]), + ("darkslategray", [0x2F, 0x4F, 0x4F]), + ("darkturquoise", [0x00, 0xCE, 0xD1]), + ("darkviolet", [0x94, 0x00, 0xD3]), + ("deeppink", [0xFF, 0x14, 0x93]), + ("deepskyblue", [0x00, 0xBF, 0xFF]), + ("dimgray", [0x69, 0x69, 0x69]), + ("dodgerblue", [0x1E, 0x90, 0xFF]), + ("firebrick", [0xB2, 0x22, 0x22]), + ("floralwhite", [0xFF, 0xFA, 0xF0]), + ("forestgreen", [0x22, 0x8B, 0x22]), + ("gainsboro", [0xDC, 0xDC, 0xDC]), + ("ghostwhite", [0xF8, 0xF8, 0xFF]), + ("gold", [0xFF, 0xD7, 0x00]), + ("goldenrod", [0xDA, 0xA5, 0x20]), + ("gray", [0x80, 0x80, 0x80]), + ("green", [0x00, 0x80, 0x00]), + ("greenyellow", [0xAD, 0xFF, 0x2F]), + ("honeydew", [0xF0, 0xFF, 0xF0]), + ("hotpink", [0xFF, 0x69, 0xB4]), + ("indianred", [0xCD, 0x5C, 0x5C]), + ("indigo", [0x4B, 0x00, 0x82]), + ("ivory", [0xFF, 0xFF, 0xF0]), + ("khaki", [0xF0, 0xE6, 0x8C]), + ("lavender", [0xE6, 0xE6, 0xFA]), + ("lavenderblush", [0xFF, 0xF0, 0xF5]), + ("lawngreen", [0x7C, 0xFC, 0x00]), + ("lemonchiffon", [0xFF, 0xFA, 0xCD]), + ("lightblue", [0xAD, 0xD8, 0xE6]), + ("lightcoral", [0xF0, 0x80, 0x80]), + ("lightcyan", [0xE0, 0xFF, 0xFF]), + ("lightgoldenrodyellow", [0xFA, 0xFA, 0xD2]), + ("lightgray", [0xD3, 0xD3, 0xD3]), + ("lightgreen", [0x90, 0xEE, 0x90]), + ("lightpink", [0xFF, 0xB6, 0xC1]), + ("lightsalmon", [0xFF, 0xA0, 0x7A]), + ("lightseagreen", [0x20, 0xB2, 0xAA]), + ("lightskyblue", [0x87, 0xCE, 0xFA]), + ("lightslategray", [0x77, 0x88, 0x99]), + ("lightsteelblue", [0xB0, 0xC4, 0xDE]), + ("lightyellow", [0xFF, 0xFF, 0xE0]), + ("lime", [0x00, 0xFF, 0x00]), + ("limegreen", [0x32, 0xCD, 0x32]), + ("linen", [0xFA, 0xF0, 0xE6]), + ("magenta", [0xFF, 0x00, 0xFF]), + ("maroon", [0x80, 0x00, 0x00]), + ("mediumaquamarine", [0x66, 0xCD, 0xAA]), + ("mediumblue", [0x00, 0x00, 0xCD]), + ("mediumorchid", [0xBA, 0x55, 0xD3]), + ("mediumpurple", [0x93, 0x70, 0xDB]), + ("mediumseagreen", [0x3C, 0xB3, 0x71]), + ("mediumslateblue", [0x7B, 0x68, 0xEE]), + ("mediumspringgreen", [0x00, 0xFA, 0x9A]), + ("mediumturquoise", [0x48, 0xD1, 0xCC]), + ("mediumvioletred", [0xC7, 0x15, 0x85]), + ("midnightblue", [0x19, 0x19, 0x70]), + ("mintcream", [0xF5, 0xFF, 0xFA]), + ("mistyrose", [0xFF, 0xE4, 0xE1]), + ("moccasin", [0xFF, 0xE4, 0xB5]), + ("navajowhite", [0xFF, 0xDE, 0xAD]), + ("navy", [0x00, 0x00, 0x80]), + ("oldlace", [0xFD, 0xF5, 0xE6]), + ("olive", [0x80, 0x80, 0x00]), + ("olivedrab", [0x6B, 0x8E, 0x23]), + ("orange", [0xFF, 0xA5, 0x00]), + ("orangered", [0xFF, 0x45, 0x00]), + ("orchid", [0xDA, 0x70, 0xD6]), + ("palegoldenrod", [0xEE, 0xE8, 0xAA]), + ("palegreen", [0x98, 0xFB, 0x98]), + ("paleturquoise", [0xAF, 0xEE, 0xEE]), + ("palevioletred", [0xDB, 0x70, 0x93]), + ("papayawhip", [0xFF, 0xEF, 0xD5]), + ("peachpuff", [0xFF, 0xDA, 0xB9]), + ("peru", [0xCD, 0x85, 0x3F]), + ("pink", [0xFF, 0xC0, 0xCB]), + ("plum", [0xDD, 0xA0, 0xDD]), + ("powderblue", [0xB0, 0xE0, 0xE6]), + ("purple", [0x80, 0x00, 0x80]), + ("rebeccapurple", [0x66, 0x33, 0x99]), + ("red", [0xFF, 0x00, 0x00]), + ("rosybrown", [0xBC, 0x8F, 0x8F]), + ("royalblue", [0x41, 0x69, 0xE1]), + ("saddlebrown", [0x8B, 0x45, 0x13]), + ("salmon", [0xFA, 0x80, 0x72]), + ("sandybrown", [0xF4, 0xA4, 0x60]), + ("seagreen", [0x2E, 0x8B, 0x57]), + ("seashell", [0xFF, 0xF5, 0xEE]), + ("sienna", [0xA0, 0x52, 0x2D]), + ("silver", [0xC0, 0xC0, 0xC0]), + ("skyblue", [0x87, 0xCE, 0xEB]), + ("slateblue", [0x6A, 0x5A, 0xCD]), + ("slategray", [0x70, 0x80, 0x90]), + ("snow", [0xFF, 0xFA, 0xFA]), + ("springgreen", [0x00, 0xFF, 0x7F]), + ("steelblue", [0x46, 0x82, 0xB4]), + ("tan", [0xD2, 0xB4, 0x8C]), + ("teal", [0x00, 0x80, 0x80]), + ("thistle", [0xD8, 0xBF, 0xD8]), + ("tomato", [0xFF, 0x63, 0x47]), + ("turquoise", [0x40, 0xE0, 0xD0]), + ("violet", [0xEE, 0x82, 0xEE]), + ("wheat", [0xF5, 0xDE, 0xB3]), + ("white", [0xFF, 0xFF, 0xFF]), + ("whitesmoke", [0xF5, 0xF5, 0xF5]), + ("yellow", [0xFF, 0xFF, 0x00]), + ("yellowgreen", [0x9A, 0xCD, 0x32]), +]; + +fn hex_of(color: Rgba) -> String { + if color.a < 1.0 { + format!( + "#{:02X}{:02X}{:02X}{:02X}", + color.r, + color.g, + color.b, + channel(color.a * 255.0) + ) + } else { + format!("#{:02X}{:02X}{:02X}", color.r, color.g, color.b) + } +} + +fn rgb_of(color: Rgba) -> String { + if color.a < 1.0 { + format!( + "rgba({}, {}, {}, {})", + color.r, + color.g, + color.b, + trim_float(color.a) + ) + } else { + format!("rgb({}, {}, {})", color.r, color.g, color.b) + } +} + +fn hsl_of(color: Rgba) -> String { + let (r, g, b) = ( + f64::from(color.r) / 255.0, + f64::from(color.g) / 255.0, + f64::from(color.b) / 255.0, + ); + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let l = (max + min) / 2.0; + let delta = max - min; + let (h, s) = if delta == 0.0 { + (0.0, 0.0) + } else { + let s = delta / (1.0 - (2.0 * l - 1.0).abs()); + let h = if max == r { + 60.0 * (((g - b) / delta).rem_euclid(6.0)) + } else if max == g { + 60.0 * ((b - r) / delta + 2.0) + } else { + 60.0 * ((r - g) / delta + 4.0) + }; + (h, s) + }; + let (h, s, l) = ( + h.round() as i64, + (s * 100.0).round() as i64, + (l * 100.0).round() as i64, + ); + if color.a < 1.0 { + format!("hsla({h}, {s}%, {l}%, {})", trim_float(color.a)) + } else { + format!("hsl({h}, {s}%, {l}%)") + } +} + +fn trim_float(value: f64) -> String { + let text = format!("{value:.2}"); + text.trim_end_matches('0').trim_end_matches('.').to_owned() +} + +pub fn domain_of(url: &str) -> Option<&str> { + let rest = match url.trim().split_once(':') { + Some((scheme, rest)) + if scheme.starts_with(|c: char| c.is_ascii_alphabetic()) + && scheme + .chars() + .all(|c| c.is_ascii_alphanumeric() || "+-.".contains(c)) => + { + rest.trim_start_matches('/') + } + _ => url.trim(), + }; + if rest.is_empty() { + return None; + } + let authority = rest.split(['/', '?', '#']).next()?; + let host = authority.rsplit('@').next()?; + let host = match host.rsplit_once(':') { + Some((name, port)) if !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) => name, + _ => host, + }; + let host = host.strip_prefix("www.").unwrap_or(host); + (!host.is_empty() && host.contains('.')).then_some(host) +} + +fn lowercase_domain(url: &str) -> Option { + domain_of(url).map(str::to_ascii_lowercase) +} + +fn one_line(text: &str) -> String { + text.lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + .join(" ") +} + +fn dedent(text: &str) -> String { + let common = text + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| &line[..line.len() - line.trim_start().len()]) + .reduce(|shared, next| { + let kept = shared + .chars() + .zip(next.chars()) + .take_while(|(a, b)| a == b) + .map(|(a, _)| a.len_utf8()) + .sum(); + &shared[..kept] + }) + .unwrap_or(""); + let mut out = text + .lines() + .map(|line| { + line.strip_prefix(common) + .unwrap_or_else(|| line.trim_start()) + }) + .collect::>() + .join("\n"); + if text.ends_with('\n') { + out.push('\n'); + } + out +} + +fn jpeg_of(png: &[u8]) -> Option> { + let decoded = image::load_from_memory(png).ok()?.to_rgba8(); + let (width, height) = decoded.dimensions(); + let mut flat = image::RgbImage::new(width, height); + for (x, y, pixel) in decoded.enumerate_pixels() { + let alpha = u32::from(pixel[3]); + let over_white = |c: u8| ((u32::from(c) * alpha + 255 * (255 - alpha)) / 255) as u8; + flat.put_pixel( + x, + y, + image::Rgb([ + over_white(pixel[0]), + over_white(pixel[1]), + over_white(pixel[2]), + ]), + ); + } + let mut out = Vec::new(); + let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, JPEG_QUALITY); + flat.write_with_encoder(encoder).ok()?; + Some(out) +} + +pub fn markdown_of_html(html: &str) -> String { + let fragment = fragment_of(html); + let mut writer = MarkdownWriter::default(); + let mut rest = fragment; + while !rest.is_empty() { + if let Some(after) = rest.strip_prefix('<') + && after + .chars() + .next() + .is_some_and(|c| c.is_ascii_alphabetic() || "/!?".contains(c)) + { + match tag_end(after) { + Some(end) => { + writer.tag(&after[..end]); + rest = &after[end + 1..]; + continue; + } + None => { + writer.text(&decode_entities(rest)); + break; + } + } + } + let first = rest.chars().next().map_or(1, char::len_utf8); + let next = rest[first..].find('<').map_or(rest.len(), |at| at + first); + writer.text(&decode_entities(&rest[..next])); + rest = &rest[next..]; + } + writer.finish() +} + +fn tag_end(after: &str) -> Option { + if after.starts_with("!--") { + return after.find("-->").map(|at| at + 2); + } + let mut quote: Option = None; + for (at, c) in after.char_indices() { + match (quote, c) { + (Some(open), c) if c == open => quote = None, + (None, '"' | '\'') => quote = Some(c), + (None, '>') => return Some(at), + _ => {} + } + } + None +} + +fn fragment_of(html: &str) -> &str { + let start = html + .find("") + .map_or(0, |at| at + "".len()); + let end = html.find("").unwrap_or(html.len()); + if start <= end { + &html[start..end] + } else { + html + } +} + +const SKIPPED: [&str; 4] = ["script", "style", "head", "title"]; + +struct MarkdownWriter { + out: String, + lists: Vec>, + link: Option<(String, usize)>, + skipping: Option<&'static str>, + preformatted: bool, + quoting: Vec, + opening: String, + fresh: bool, + inline: Vec<&'static str>, + last_closed: Option<(&'static str, usize)>, + row: Option>, + cell: Option, + rows: usize, +} + +impl Default for MarkdownWriter { + fn default() -> Self { + Self { + out: String::new(), + lists: Vec::new(), + link: None, + skipping: None, + preformatted: false, + quoting: Vec::new(), + opening: String::new(), + fresh: true, + inline: Vec::new(), + last_closed: None, + row: None, + cell: None, + rows: 0, + } + } +} + +impl MarkdownWriter { + fn tag(&mut self, raw: &str) { + let raw = raw.trim(); + if raw.starts_with('!') { + return; + } + let closing = raw.starts_with('/'); + let body = raw.trim_start_matches('/').trim_end_matches('/').trim(); + let name = body + .split(|c: char| c.is_whitespace()) + .next() + .unwrap_or_default() + .to_ascii_lowercase(); + if let Some(skipped) = self.skipping { + if closing && name == skipped { + self.skipping = None; + } + return; + } + match (name.as_str(), closing) { + ("script" | "style" | "head" | "title", false) => { + self.skipping = SKIPPED.iter().copied().find(|tag| *tag == name); + } + ("br", _) => { + if !self.at_line_start() { + self.out.push('\n'); + self.fresh = true; + } + } + ("p" | "div" | "section" | "article", _) => self.blank_line(), + ("table", false) => { + self.blank_line(); + self.rows = 0; + } + ("table", true) => { + self.row = None; + self.blank_line(); + } + ("tr", false) => self.row = Some(Vec::new()), + ("tr", true) => self.end_row(), + ("td" | "th", false) => self.cell = Some(self.out.len()), + ("td" | "th", true) => self.end_cell(), + ("h1" | "h2" | "h3" | "h4" | "h5" | "h6", false) => { + self.blank_line(); + let level = name[1..].parse::().unwrap_or(1); + self.out.push_str(&"#".repeat(level)); + self.out.push(' '); + } + ("h1" | "h2" | "h3" | "h4" | "h5" | "h6", true) => self.blank_line(), + ("b" | "strong" | "i" | "em" | "code" | "span", false) => { + let marker = inline_marker(&name, attribute(body, "style").as_deref()); + let marker = if self.preformatted { "" } else { marker }; + self.inline.push(marker); + self.open(marker); + } + ("b" | "strong" | "i" | "em" | "code" | "span", true) => { + if let Some(marker) = self.inline.pop() { + self.close(marker); + } + } + ("pre", false) => { + self.blank_line(); + self.out.push_str("```\n"); + self.preformatted = true; + } + ("pre", true) => { + self.preformatted = false; + self.newline(); + self.out.push_str("```"); + self.blank_line(); + } + ("ul", false) => { + self.blank_line_if_top_level(); + self.lists.push(None); + } + ("ol", false) => { + self.blank_line_if_top_level(); + self.lists.push(Some(0)); + } + ("ul" | "ol", true) => { + self.lists.pop(); + if self.lists.is_empty() { + self.blank_line(); + } + } + ("li", false) => { + self.newline(); + let depth = self.lists.len().saturating_sub(1); + self.out.push_str(&" ".repeat(depth)); + match self.lists.last_mut() { + Some(Some(count)) => { + *count += 1; + self.out.push_str(&format!("{count}. ")); + } + _ => self.out.push_str("- "), + } + } + ("blockquote", false) => { + self.blank_line(); + self.quoting.push(self.out.len()); + } + ("blockquote", true) => { + if let Some(from) = self.quoting.pop() { + self.newline(); + let quoted = self.out[from..] + .trim_end() + .lines() + .map(|line| { + if line.is_empty() { + ">".to_owned() + } else { + format!("> {line}") + } + }) + .collect::>() + .join("\n"); + self.truncate_to(from); + self.out.push_str("ed); + self.fresh = false; + } + self.blank_line(); + } + ("a", false) => { + let href = attribute(body, "href").unwrap_or_default(); + self.link = Some((href, self.out.len())); + } + ("a", true) => { + if let Some((href, from)) = self.link.take() { + let label = self.out[from..].trim().to_owned(); + self.truncate_to(from); + if href.is_empty() { + self.out.push_str(&label); + } else { + self.out.push_str(&format!("[{label}]({href})")); + } + } + } + ("img", false) => { + let alt = attribute(body, "alt").unwrap_or_default(); + let src = attribute(body, "src").unwrap_or_default(); + self.out.push_str(&format!("![{alt}]({src})")); + self.fresh = false; + } + ("hr", _) => { + self.blank_line(); + self.out.push_str("---"); + self.fresh = false; + self.blank_line(); + } + _ => {} + } + } + + fn end_cell(&mut self) { + let Some(from) = self.cell.take() else { + return; + }; + if self.row.is_none() { + return; + } + let text = self.out[from..] + .trim() + .replace('\n', " ") + .replace('|', "\\|"); + self.truncate_to(from); + if let Some(row) = &mut self.row { + row.push(text); + } + } + + fn end_row(&mut self) { + self.end_cell(); + let Some(row) = self.row.take() else { + return; + }; + if row.is_empty() { + return; + } + self.newline(); + self.out.push_str(&format!("| {} |", row.join(" | "))); + if self.rows == 0 { + self.out + .push_str(&format!("\n|{}", " --- |".repeat(row.len()))); + } + self.rows += 1; + self.fresh = false; + self.newline(); + } + + fn open(&mut self, marker: &'static str) { + if let Some((closed, at)) = self.last_closed.take() + && closed == marker + && at == self.out.len() + { + self.truncate_to(at - marker.len()); + return; + } + self.opening.push_str(marker); + } + + fn close(&mut self, marker: &'static str) { + if marker.is_empty() { + return; + } + if let Some(unopened) = self.opening.strip_suffix(marker) { + self.opening = unopened.to_owned(); + return; + } + if self.preformatted { + self.out.push_str(marker); + return; + } + let kept = self.out.trim_end_matches(' ').len(); + let had_space = kept < self.out.len(); + self.truncate_to(kept); + self.out.push_str(marker); + self.last_closed = Some((marker, self.out.len())); + if had_space { + self.out.push(' '); + } + } + + fn text(&mut self, text: &str) { + if self.skipping.is_some() { + return; + } + if self.preformatted { + let opening = std::mem::take(&mut self.opening); + self.out.push_str(&opening); + self.out.push_str(text); + self.fresh = text.ends_with('\n'); + return; + } + let leading = text.starts_with(char::is_whitespace); + let trailing = text.ends_with(char::is_whitespace); + let words: Vec<&str> = text.split_whitespace().collect(); + if leading && !self.at_line_start() && !self.out.ends_with(' ') { + self.out.push(' '); + } + if words.is_empty() { + return; + } + let opening = std::mem::take(&mut self.opening); + self.out.push_str(&opening); + self.out.push_str(&words.join(" ")); + self.fresh = false; + if trailing { + self.out.push(' '); + } + } + + fn at_line_start(&self) -> bool { + self.fresh + } + + fn truncate_to(&mut self, len: usize) { + self.out.truncate(len); + if let Some((_, from)) = &mut self.link { + *from = (*from).min(len); + } + if let Some(from) = &mut self.cell { + *from = (*from).min(len); + } + for from in &mut self.quoting { + *from = (*from).min(len); + } + } + + fn newline(&mut self) { + let trimmed = self.out.trim_end_matches(' ').len(); + self.truncate_to(trimmed); + if !self.out.is_empty() && !self.out.ends_with('\n') { + self.out.push('\n'); + } + self.fresh = true; + } + + fn blank_line(&mut self) { + self.newline(); + if !self.out.is_empty() && !self.out.ends_with("\n\n") { + self.out.push('\n'); + } + } + + fn blank_line_if_top_level(&mut self) { + if self.lists.is_empty() { + self.blank_line(); + } + } + + fn finish(mut self) -> String { + self.newline(); + let mut lines: Vec<&str> = self.out.lines().map(str::trim_end).collect(); + lines.dedup_by(|a, b| a.is_empty() && b.is_empty()); + lines.join("\n").trim_end().to_owned() + } +} + +fn rule_of(style: &str, property: &str) -> Option { + style + .split(';') + .filter_map(|rule| rule.split_once(':')) + .find(|(key, _)| key.trim() == property) + .map(|(_, value)| value.trim().to_owned()) +} + +fn inline_marker(name: &str, style: Option<&str>) -> &'static str { + let style = style.unwrap_or_default().to_ascii_lowercase(); + let weight = rule_of(&style, "font-weight"); + let bold = match (name, weight.as_deref()) { + ("b" | "strong", Some("normal" | "400" | "300" | "200" | "100")) => false, + ("b" | "strong", _) => true, + (_, Some("bold" | "bolder" | "600" | "700" | "800" | "900")) => true, + _ => false, + }; + let slant = rule_of(&style, "font-style"); + let italic = match (name, slant.as_deref()) { + ("i" | "em", Some("normal")) => false, + ("i" | "em", _) => true, + (_, Some("italic" | "oblique")) => true, + _ => false, + }; + match (name, bold, italic) { + ("code", _, _) => "`", + (_, true, true) => "***", + (_, true, false) => "**", + (_, false, true) => "*", + _ => "", + } +} + +fn attribute(tag: &str, name: &str) -> Option { + let lower = tag.to_ascii_lowercase(); + let wanted = format!("{name}="); + let at = lower + .match_indices(&wanted) + .map(|(at, _)| at) + .find(|at| lower[..*at].ends_with(char::is_whitespace)) + .map(|at| at + wanted.len())?; + let rest = &tag[at..]; + let value = match rest.chars().next()? { + quote @ ('"' | '\'') => rest[1..].split(quote).next()?, + _ => rest.split(char::is_whitespace).next()?, + }; + Some(decode_entities(value)) +} + +const NAMED_ENTITIES: [(&str, char); 84] = [ + ("iexcl", '¡'), + ("cent", '¢'), + ("pound", '£'), + ("euro", '€'), + ("yen", '¥'), + ("sect", '§'), + ("copy", '©'), + ("laquo", '«'), + ("reg", '®'), + ("deg", '°'), + ("plusmn", '±'), + ("micro", 'µ'), + ("para", '¶'), + ("middot", '·'), + ("raquo", '»'), + ("frac12", '½'), + ("iquest", '¿'), + ("times", '×'), + ("divide", '÷'), + ("ndash", '–'), + ("mdash", '—'), + ("lsquo", '‘'), + ("rsquo", '’'), + ("ldquo", '“'), + ("rdquo", '”'), + ("hellip", '…'), + ("trade", '™'), + ("bull", '•'), + ("Agrave", 'À'), + ("Aacute", 'Á'), + ("Acirc", 'Â'), + ("Atilde", 'Ã'), + ("Auml", 'Ä'), + ("Aring", 'Å'), + ("AElig", 'Æ'), + ("Ccedil", 'Ç'), + ("Egrave", 'È'), + ("Eacute", 'É'), + ("Ecirc", 'Ê'), + ("Euml", 'Ë'), + ("Igrave", 'Ì'), + ("Iacute", 'Í'), + ("Icirc", 'Î'), + ("Iuml", 'Ï'), + ("Ntilde", 'Ñ'), + ("Ograve", 'Ò'), + ("Oacute", 'Ó'), + ("Ocirc", 'Ô'), + ("Otilde", 'Õ'), + ("Ouml", 'Ö'), + ("Oslash", 'Ø'), + ("Ugrave", 'Ù'), + ("Uacute", 'Ú'), + ("Ucirc", 'Û'), + ("Uuml", 'Ü'), + ("szlig", 'ß'), + ("agrave", 'à'), + ("aacute", 'á'), + ("acirc", 'â'), + ("atilde", 'ã'), + ("auml", 'ä'), + ("aring", 'å'), + ("aelig", 'æ'), + ("ccedil", 'ç'), + ("egrave", 'è'), + ("eacute", 'é'), + ("ecirc", 'ê'), + ("euml", 'ë'), + ("igrave", 'ì'), + ("iacute", 'í'), + ("icirc", 'î'), + ("iuml", 'ï'), + ("ntilde", 'ñ'), + ("ograve", 'ò'), + ("oacute", 'ó'), + ("ocirc", 'ô'), + ("otilde", 'õ'), + ("ouml", 'ö'), + ("oslash", 'ø'), + ("ugrave", 'ù'), + ("uacute", 'ú'), + ("ucirc", 'û'), + ("uuml", 'ü'), + ("yacute", 'ý'), +]; + +fn named_entity(name: &str) -> Option { + NAMED_ENTITIES + .iter() + .find(|(known, _)| *known == name) + .map(|(_, c)| *c) +} + +fn decode_entities(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(at) = rest.find('&') { + out.push_str(&rest[..at]); + let after = &rest[at + 1..]; + let Some(end) = after.find(';').filter(|end| *end <= 10) else { + out.push('&'); + rest = after; + continue; + }; + let entity = &after[..end]; + let decoded = match entity { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" => Some('\''), + "nbsp" => Some(' '), + named if named.chars().all(|c| c.is_ascii_alphabetic()) => named_entity(named), + _ => entity + .strip_prefix('#') + .and_then(|number| match number.strip_prefix(['x', 'X']) { + Some(hex) => u32::from_str_radix(hex, 16).ok(), + None => number.parse().ok(), + }) + .and_then(char::from_u32), + }; + match decoded { + Some(c) => { + out.push(c); + rest = &after[end + 1..]; + } + None => { + out.push('&'); + rest = after; + } + } + } + out.push_str(rest); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text_of(kind: Kind, text: &str) -> Content<'_> { + Content { + kind: Some(kind), + text: Some(text.into()), + ..Default::default() + } + } + + fn rendered(form: Form, content: &Content) -> String { + match render(form, content).expect("se puede") { + Rendered::Text(text) => text, + other => panic!("no era texto: {other:?}"), + } + } + + #[test] + fn every_form_has_a_stable_name_that_comes_back() { + for form in Form::ALL { + assert_eq!(Form::from_name(form.as_str()), Some(form)); + } + let mut names: Vec<&str> = Form::ALL.iter().map(|form| form.as_str()).collect(); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), Form::ALL.len()); + assert_eq!(Form::from_name("Markdown"), None, "el nombre es exacto"); + } + + #[test] + fn what_was_rendered_can_be_written_back_as_an_item() { + let text = Rendered::Text("hola".into()).into_item(); + assert_eq!(text.formats[0].id, crate::item::SYNTHETIC_TEXT); + assert_eq!( + text.fingerprint(), + crate::item::Item::plain("hola").fingerprint() + ); + let jpeg = Rendered::Jpeg(vec![0xFF, 0xD8, 0xFF]).into_item(); + assert_eq!(jpeg.kind, Some(Kind::Image)); + assert_eq!(jpeg.formats[0].id, crate::item::SYNTHETIC_JPEG); + assert!(matches!( + jpeg.formats[0].payload, + crate::item::Payload::Inline(ref bytes) if bytes == &[0xFF, 0xD8, 0xFF] + )); + let content = Content { + text: Some("x".into()), + ..Default::default() + }; + let owned = Content { + text: Some(String::from("propio").into()), + ..content.clone() + }; + assert_eq!( + owned.text.as_deref(), + Some("propio"), + "el texto puede ser prestado o propio" + ); + } + + #[test] + fn plain_text_has_no_forms_of_its_own() { + assert!(forms_for(&text_of(Kind::Text, "hola")).is_empty()); + assert!(forms_for(&Content::default()).is_empty()); + } + + #[test] + fn every_form_that_is_offered_can_be_rendered() { + let png = tiny_png(); + let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjcC0zIn0.firma"; + let contents = [ + Content { + kind: Some(Kind::Text), + text: Some("hola".into()), + html: Some("hola".into()), + rich: true, + ..Default::default() + }, + text_of(Kind::Json, r#"[{"a": 1}, {"b": 2}]"#), + text_of(Kind::Json, "[1, 2]"), + text_of(Kind::Color, "hsla(10, 20%, 30%, 40%)"), + text_of(Kind::Color, "#FFA500"), + Content { + title: Some("Ejemplo".into()), + ..text_of(Kind::Link, "https://ejemplo.test/a b") + }, + text_of(Kind::Link, "https://localhost/"), + text_of(Kind::Code, " x\n y"), + text_of(Kind::Token, jwt), + text_of(Kind::Token, "ghp_not_a_real_token_for_tests_0000000000"), + Content { + kind: Some(Kind::Image), + image: Some(&png), + ocr: Some("leído"), + paths: vec!["/tmp/a.png".into()], + ..Default::default() + }, + ]; + for content in &contents { + let forms = forms_for(content); + assert!(!forms.is_empty(), "{content:?}"); + for form in forms { + assert!( + render(form, content).is_some(), + "{form:?} sobre {content:?}" + ); + } + } + } + + #[test] + fn a_rich_text_offers_plain_and_markdown_only_with_html() { + let with_html = Content { + kind: Some(Kind::Text), + text: Some("hola".into()), + html: Some("hola".into()), + rich: true, + ..Default::default() + }; + assert_eq!(forms_for(&with_html), vec![Form::PlainText, Form::Markdown]); + assert_eq!(rendered(Form::Markdown, &with_html), "**hola**"); + let spaced = Content { + text: Some(" hola \n".into()), + ..with_html.clone() + }; + assert_eq!( + rendered(Form::PlainText, &spaced), + " hola \n", + "el texto plano se entrega como se copió, sin recortar" + ); + let rtf_only = Content { + rich: true, + ..text_of(Kind::Text, "hola") + }; + assert_eq!(forms_for(&rtf_only), vec![Form::PlainText]); + } + + #[test] + fn json_is_offered_formatted_minified_by_keys_and_as_a_table() { + let content = text_of( + Kind::Json, + r#"{"b": 1, "a": {"x": [1, 2]}, "c": "hola\tque tal"}"#, + ); + assert_eq!( + forms_for(&content), + vec![ + Form::JsonPretty, + Form::JsonMinified, + Form::JsonKeys, + Form::JsonTable + ] + ); + assert_eq!( + rendered(Form::JsonPretty, &content), + "{\n \"b\": 1,\n \"a\": {\n \"x\": [\n 1,\n 2\n ]\n },\n \"c\": \"hola\\tque tal\"\n}", + "en el orden en que estaban las claves" + ); + assert_eq!( + rendered(Form::JsonMinified, &content), + r#"{"b":1,"a":{"x":[1,2]},"c":"hola\tque tal"}"# + ); + assert_eq!(rendered(Form::JsonKeys, &content), "b\na\nc"); + assert_eq!( + rendered(Form::JsonTable, &content), + "b\t1\na\t{\"x\":[1,2]}\nc\thola que tal", + "una tabulación entre clave y valor; los anidados van minificados" + ); + } + + #[test] + fn a_list_of_objects_becomes_a_table_with_a_header() { + let content = text_of( + Kind::Json, + r#"[{"name": "ana", "age": 3}, {"name": "bo", "city": "Lima"}]"#, + ); + assert_eq!(rendered(Form::JsonKeys, &content), "name\nage\ncity"); + assert_eq!( + rendered(Form::JsonTable, &content), + "name\tage\tcity\nana\t3\t\nbo\t\tLima", + "las columnas son la unión de claves; lo que falta queda vacío" + ); + } + + #[test] + fn json_that_is_not_an_object_has_no_keys_and_no_table() { + let content = text_of(Kind::Json, "[1, 2, 3]"); + assert_eq!( + forms_for(&content), + vec![Form::JsonPretty, Form::JsonMinified] + ); + assert_eq!(render(Form::JsonKeys, &content), None); + let broken = text_of(Kind::Json, "{no es json}"); + assert!(forms_for(&broken).is_empty()); + } + + #[test] + fn a_colour_goes_round_the_three_notations() { + let content = text_of(Kind::Color, "#FF8800"); + assert_eq!( + forms_for(&content), + vec![ + Form::ColorHex, + Form::ColorRgb, + Form::ColorHsl, + Form::ColorName + ] + ); + assert_eq!(rendered(Form::ColorHex, &content), "#FF8800"); + assert_eq!(rendered(Form::ColorRgb, &content), "rgb(255, 136, 0)"); + assert_eq!(rendered(Form::ColorHsl, &content), "hsl(32, 100%, 50%)"); + assert_eq!( + rendered(Form::ColorHex, &text_of(Kind::Color, "rgb(255, 136, 0)")), + "#FF8800" + ); + assert_eq!( + rendered(Form::ColorRgb, &text_of(Kind::Color, "hsl(32, 100%, 50%)")), + "rgb(255, 136, 0)" + ); + assert_eq!( + rendered(Form::ColorHex, &text_of(Kind::Color, "#f80")), + "#FF8800" + ); + } + + #[test] + fn a_colour_that_has_a_css_name_is_called_by_it() { + for (text, name) in [ + ("#FFA500", "orange"), + ("#ffa500", "orange"), + ("rgb(255, 0, 0)", "red"), + ("hsl(0, 0%, 50%)", "gray"), + ("#000", "black"), + ("#fff", "white"), + ("#663399", "rebeccapurple"), + ("rgba(255, 165, 0, 1)", "orange"), + ] { + assert_eq!(rendered(Form::ColorName, &text_of(Kind::Color, text)), name); + } + } + + #[test] + fn a_colour_close_to_a_name_borrows_it() { + for (text, name) in [ + ("#FF8800", "darkorange"), + ("#FF9500", "darkorange"), + ("#FFCC00", "gold"), + ("#050505", "black"), + ("#FEFEFE", "white"), + ("#F0F0F0", "whitesmoke"), + ("#0000CC", "mediumblue"), + ] { + assert_eq!(rendered(Form::ColorName, &text_of(Kind::Color, text)), name); + } + } + + #[test] + fn a_colour_far_from_every_name_is_not_offered_one() { + for text in [ + "#123456", "#3B82F6", "#1DB954", "#222222", "#00CC00", "#CC0000", + ] { + let content = text_of(Kind::Color, text); + assert!(!forms_for(&content).contains(&Form::ColorName), "{text}"); + assert_eq!(render(Form::ColorName, &content), None, "{text}"); + } + } + + #[test] + fn a_translucent_colour_has_no_name() { + for text in [ + "#FF880080", + "rgba(255, 165, 0, 0.5)", + "hsla(39, 100%, 50%, 99%)", + ] { + let content = text_of(Kind::Color, text); + assert!(!forms_for(&content).contains(&Form::ColorName), "{text}"); + assert_eq!(render(Form::ColorName, &content), None, "{text}"); + } + } + + #[test] + fn the_spelling_that_wins_is_the_common_one() { + for (text, name) in [ + ("#00FFFF", "cyan"), + ("#FF00FF", "magenta"), + ("#808080", "gray"), + ("#2F4F4F", "darkslategray"), + ("#A9A9A9", "darkgray"), + ] { + assert_eq!(rendered(Form::ColorName, &text_of(Kind::Color, text)), name); + } + } + + #[test] + fn the_named_table_has_no_two_names_for_one_colour() { + let mut values: Vec<[u8; 3]> = NAMED.iter().map(|(_, rgb)| *rgb).collect(); + values.sort_unstable(); + values.dedup(); + assert_eq!(values.len(), NAMED.len()); + for (name, _) in NAMED { + assert!(name.bytes().all(|b| b.is_ascii_lowercase()), "{name}"); + } + } + + #[test] + fn the_distance_is_a_metric_that_weighs_green_most() { + assert_eq!(redmean_squared([0, 0, 0], [0, 0, 0]), 0); + assert_eq!( + redmean_squared([10, 20, 30], [40, 50, 60]), + redmean_squared([40, 50, 60], [10, 20, 30]) + ); + let red = redmean_squared([128, 0, 0], [128 + 15, 0, 0]); + let green = redmean_squared([0, 128, 0], [0, 128 + 15, 0]); + let blue = redmean_squared([0, 0, 128], [0, 0, 128 + 15]); + assert!(green > red && green > blue); + assert!( + redmean_squared([250, 0, 0], [235, 0, 0]) > redmean_squared([10, 0, 0], [25, 0, 0]), + "una diferencia de rojo pesa más donde hay mucho rojo" + ); + assert!( + redmean_squared([250, 0, 0], [250, 0, 15]) < redmean_squared([10, 0, 0], [10, 0, 15]), + "y una de azul pesa más donde hay poco rojo" + ); + assert_eq!( + redmean_squared([255, 0, 0], [240, 0, 0]), + (1024 + 495) * 225 + ); + assert_eq!( + redmean_squared([0, 0, 255], [0, 0, 240]), + (1024 + 510) * 225 + ); + assert!(named_color(parse_color("#FF9500").unwrap()).is_some()); + assert_eq!(NAME_WITHIN, 30); + } + + #[test] + fn transparency_survives_every_notation() { + let content = text_of(Kind::Color, "rgba(255, 136, 0, 0.5)"); + assert_eq!(rendered(Form::ColorHex, &content), "#FF880080"); + assert_eq!(rendered(Form::ColorRgb, &content), "rgba(255, 136, 0, 0.5)"); + assert_eq!( + rendered(Form::ColorHsl, &content), + "hsla(32, 100%, 50%, 0.5)" + ); + assert_eq!( + rendered(Form::ColorRgb, &text_of(Kind::Color, "#FF880080")), + "rgba(255, 136, 0, 0.5)" + ); + } + + #[test] + fn grey_has_no_hue_and_the_extremes_do_not_divide_by_zero() { + assert_eq!( + rendered(Form::ColorHsl, &text_of(Kind::Color, "#808080")), + "hsl(0, 0%, 50%)" + ); + assert_eq!( + rendered(Form::ColorHsl, &text_of(Kind::Color, "#000000")), + "hsl(0, 0%, 0%)" + ); + assert_eq!( + rendered(Form::ColorHsl, &text_of(Kind::Color, "#FFFFFF")), + "hsl(0, 0%, 100%)" + ); + assert_eq!( + rendered( + Form::ColorRgb, + &text_of(Kind::Color, "hsl(400, 150%, -10%)") + ), + "rgb(0, 0, 0)", + "fuera de rango se recorta, no se rompe" + ); + } + + #[test] + fn every_sextant_of_the_hue_wheel_round_trips_against_a_reference_table() { + for (hex, hsl) in [ + ("#FF0000", "hsl(0, 100%, 50%)"), + ("#FFFF00", "hsl(60, 100%, 50%)"), + ("#00FF00", "hsl(120, 100%, 50%)"), + ("#00FFFF", "hsl(180, 100%, 50%)"), + ("#0000FF", "hsl(240, 100%, 50%)"), + ("#FF00FF", "hsl(300, 100%, 50%)"), + ("#FF0080", "hsl(330, 100%, 50%)"), + ("#80FF00", "hsl(90, 100%, 50%)"), + ("#00FF80", "hsl(150, 100%, 50%)"), + ("#0080FF", "hsl(210, 100%, 50%)"), + ("#8000FF", "hsl(270, 100%, 50%)"), + ("#3498DB", "hsl(204, 70%, 53%)"), + ("#2ECC71", "hsl(145, 63%, 49%)"), + ("#9B59B6", "hsl(283, 39%, 53%)"), + ("#E67E22", "hsl(28, 80%, 52%)"), + ("#1A0B2E", "hsl(266, 61%, 11%)"), + ("#F0E68C", "hsl(54, 77%, 75%)"), + ] { + assert_eq!( + rendered(Form::ColorHsl, &text_of(Kind::Color, hex)), + hsl, + "{hex}" + ); + } + for (hsl, hex) in [ + ("hsl(0, 100%, 50%)", "#FF0000"), + ("hsl(60, 100%, 50%)", "#FFFF00"), + ("hsl(120, 100%, 50%)", "#00FF00"), + ("hsl(180, 100%, 50%)", "#00FFFF"), + ("hsl(240, 100%, 50%)", "#0000FF"), + ("hsl(300, 100%, 50%)", "#FF00FF"), + ("hsl(330, 100%, 50%)", "#FF0080"), + ("hsl(32, 50%, 50%)", "#BF8440"), + ("hsl(210, 40%, 70%)", "#94B2D1"), + ("hsl(90, 60%, 30%)", "#4D7A1F"), + ("hsl(270, 25%, 80%)", "#CCBFD9"), + ("hsl(15, 80%, 20%)", "#5C1F0A"), + ("hsl(400, 50%, 50%)", "#BF9540"), + ("hsl(-60, 100%, 50%)", "#FF00FF"), + ] { + assert_eq!( + rendered(Form::ColorHex, &text_of(Kind::Color, hsl)), + hex, + "{hsl}" + ); + } + } + + #[test] + fn short_and_eight_digit_hex_expand_digit_by_digit() { + assert_eq!( + rendered(Form::ColorRgb, &text_of(Kind::Color, "#0af")), + "rgb(0, 170, 255)" + ); + assert_eq!( + rendered(Form::ColorRgb, &text_of(Kind::Color, "#FF880099")), + "rgba(255, 136, 0, 0.6)" + ); + assert_eq!( + rendered(Form::ColorRgb, &text_of(Kind::Color, "#12345678")), + "rgba(18, 52, 86, 0.47)" + ); + assert_eq!( + rendered( + Form::ColorHex, + &text_of(Kind::Color, "hsla(200, 50%, 40%, 0.25)") + ), + "#33779940" + ); + } + + #[test] + fn a_colour_that_does_not_parse_offers_nothing_extra() { + assert!(forms_for(&text_of(Kind::Color, "#GGGGGG")).is_empty()); + assert_eq!(parse_color("rgb(1, 2)"), None); + assert_eq!(parse_color("hsl(1, 2, 3, 4, 5)"), None); + assert_eq!(parse_color("#12345"), None); + } + + #[test] + fn a_link_is_offered_as_markdown_domain_and_with_its_title() { + let bare = text_of(Kind::Link, "https://www.ejemplo.test/ruta?x=1#f"); + assert_eq!(forms_for(&bare), vec![Form::LinkMarkdown, Form::LinkDomain]); + assert_eq!( + rendered(Form::LinkMarkdown, &bare), + "[https://www.ejemplo.test/ruta?x=1#f](https://www.ejemplo.test/ruta?x=1#f)" + ); + assert_eq!(rendered(Form::LinkDomain, &bare), "ejemplo.test"); + assert_eq!( + rendered( + Form::LinkDomain, + &text_of(Kind::Link, "https://EJEMPLO.TEST/X") + ), + "ejemplo.test", + "un dominio no distingue mayúsculas" + ); + let titled = Content { + title: Some(" Ejemplo, la página ".into()), + ..bare.clone() + }; + assert_eq!(forms_for(&titled).last(), Some(&Form::LinkTitled)); + assert_eq!( + rendered(Form::LinkMarkdown, &titled), + "[Ejemplo, la página](https://www.ejemplo.test/ruta?x=1#f)" + ); + assert_eq!( + rendered(Form::LinkTitled, &titled), + "Ejemplo, la página — https://www.ejemplo.test/ruta?x=1#f" + ); + } + + #[test] + fn the_domain_drops_credentials_port_and_path_but_not_a_subdomain() { + assert_eq!( + domain_of("https://user:pw@api.ejemplo.test:8443/v1"), + Some("api.ejemplo.test") + ); + assert_eq!( + domain_of("ftp://files.ejemplo.test"), + Some("files.ejemplo.test") + ); + assert_eq!( + domain_of("mailto:alguien@ejemplo.test"), + Some("ejemplo.test") + ); + assert_eq!( + domain_of("https://localhost:3000/"), + None, + "sin punto no es dominio" + ); + assert_eq!(domain_of("https://"), None); + assert_eq!(domain_of(""), None); + assert_eq!( + domain_of("192.168.0.1:8080"), + Some("192.168.0.1"), + "sin esquema, lo de antes del puerto es el host" + ); + assert_eq!( + domain_of("https://ejemplo.test:abc/"), + Some("ejemplo.test:abc"), + "un puerto que no es número no se recorta" + ); + assert_eq!(domain_of("https://ejemplo.test:/"), Some("ejemplo.test:")); + assert_eq!( + domain_of(":ejemplo.test"), + Some(":ejemplo.test"), + "un esquema vacío no es esquema" + ); + } + + #[test] + fn code_can_be_one_line_a_block_or_dedented() { + let source = " fn main() {\n println!(\"hola\");\n }\n"; + let content = text_of(Kind::Code, source); + assert_eq!( + forms_for(&content), + vec![Form::CodeOneLine, Form::CodeBlock, Form::CodeDedented] + ); + assert_eq!( + rendered(Form::CodeOneLine, &content), + "fn main() { println!(\"hola\"); }" + ); + assert_eq!( + rendered(Form::CodeBlock, &content), + "```\nfn main() {\n println!(\"hola\");\n }\n```" + ); + assert_eq!( + rendered(Form::CodeDedented, &content), + "fn main() {\n println!(\"hola\");\n}\n", + "se quita lo que todas las líneas comparten y nada más" + ); + assert_eq!( + dedent("\n a\n\n b\n"), + "\na\n\n b\n", + "las líneas vacías no cuentan" + ); + } + + #[test] + fn a_token_becomes_a_header_or_a_curl_and_a_jwt_shows_its_claims() { + let opaque = text_of(Kind::Token, "ghp_not_a_real_token_for_tests_0000000000"); + assert_eq!(forms_for(&opaque), vec![Form::TokenHeader, Form::TokenCurl]); + assert_eq!( + rendered(Form::TokenHeader, &opaque), + "Authorization: Bearer ghp_not_a_real_token_for_tests_0000000000" + ); + assert_eq!( + rendered(Form::TokenCurl, &opaque), + "curl -H 'Authorization: Bearer ghp_not_a_real_token_for_tests_0000000000' \"$URL\"" + ); + let jwt = text_of( + Kind::Token, + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjcC0zIiwiZW52Ijoic3RhZ2luZyJ9.firma", + ); + assert_eq!(forms_for(&jwt).last(), Some(&Form::TokenClaims)); + assert_eq!( + rendered(Form::TokenClaims, &jwt), + "{\n \"sub\": \"cp-3\",\n \"env\": \"staging\"\n}" + ); + } + + fn tiny_png() -> Vec { + let mut out = Vec::new(); + let mut image = image::RgbaImage::new(2, 2); + image.put_pixel(0, 0, image::Rgba([255, 0, 0, 255])); + image.put_pixel(1, 0, image::Rgba([0, 0, 0, 0])); + image.put_pixel(0, 1, image::Rgba([0, 255, 0, 255])); + image.put_pixel(1, 1, image::Rgba([0, 0, 255, 128])); + image + .write_with_encoder(image::codecs::png::PngEncoder::new(&mut out)) + .expect("png"); + out + } + + fn solid_png(pixel: [u8; 4]) -> Vec { + let mut out = Vec::new(); + let image = image::RgbaImage::from_pixel(16, 16, image::Rgba(pixel)); + image + .write_with_encoder(image::codecs::png::PngEncoder::new(&mut out)) + .expect("png"); + out + } + + fn middle_of_jpeg(bytes: &[u8]) -> [u8; 3] { + assert_eq!(&bytes[..2], &[0xFF, 0xD8], "cabecera JPEG"); + let back = image::load_from_memory(bytes).expect("se lee").to_rgb8(); + back.get_pixel(8, 8).0 + } + + #[test] + fn an_image_is_offered_as_jpeg_and_as_what_was_read_in_it() { + let png = tiny_png(); + let silent = Content { + kind: Some(Kind::Image), + image: Some(&png), + ..Default::default() + }; + assert_eq!(forms_for(&silent), vec![Form::ImageJpeg]); + let read = Content { + ocr: Some(" Pedido 4417 "), + ..silent.clone() + }; + assert_eq!(forms_for(&read), vec![Form::ImageJpeg, Form::ImageOcr]); + assert_eq!(rendered(Form::ImageOcr, &read), "Pedido 4417"); + let blank = Content { + ocr: Some(" "), + ..silent.clone() + }; + assert_eq!(forms_for(&blank), vec![Form::ImageJpeg]); + } + + #[test] + fn the_jpeg_is_a_real_jpeg_with_transparency_laid_on_white() { + let near = |got: [u8; 3], wanted: [u8; 3]| { + got.iter() + .zip(wanted) + .all(|(got, wanted)| got.abs_diff(wanted) <= 6) + }; + let jpeg_of_solid = |pixel: [u8; 4]| { + let png = solid_png(pixel); + let content = Content { + image: Some(&png), + ..Default::default() + }; + match render(Form::ImageJpeg, &content) { + Some(Rendered::Jpeg(bytes)) => middle_of_jpeg(&bytes), + other => panic!("no salió un JPEG: {other:?}"), + } + }; + assert!( + near(jpeg_of_solid([200, 30, 30, 255]), [200, 30, 30]), + "opaco intacto" + ); + assert!( + near(jpeg_of_solid([0, 0, 0, 0]), [255, 255, 255]), + "lo transparente se vuelve blanco, no negro" + ); + assert!( + near(jpeg_of_solid([0, 0, 255, 128]), [127, 127, 255]), + "azul al 50 % sobre blanco" + ); + assert!( + near(jpeg_of_solid([0, 0, 255, 64]), [191, 191, 255]), + "azul al 25 % sobre blanco" + ); + assert!( + near(jpeg_of_solid([200, 30, 30, 128]), [227, 142, 142]), + "un rojo apagado al 50 % se aclara canal a canal" + ); + assert_eq!( + render(Form::ImageJpeg, &text_of(Kind::Image, "no bytes")), + None + ); + let broken = Content { + image: Some(b"no es png"), + ..Default::default() + }; + assert_eq!(render(Form::ImageJpeg, &broken), None); + } + + #[test] + fn a_tiff_from_safari_becomes_a_jpeg_too() { + let mut tiff = Vec::new(); + let image = image::RgbaImage::from_pixel(16, 16, image::Rgba([10, 200, 30, 255])); + image + .write_with_encoder(image::codecs::tiff::TiffEncoder::new(std::io::Cursor::new( + &mut tiff, + ))) + .expect("tiff"); + let content = Content { + kind: Some(Kind::Image), + image: Some(&tiff), + ..Default::default() + }; + let Some(Rendered::Jpeg(bytes)) = render(Form::ImageJpeg, &content) else { + panic!("un TIFF también se vuelve JPEG"); + }; + let middle = middle_of_jpeg(&bytes); + assert!( + middle[0].abs_diff(10) <= 6 + && middle[1].abs_diff(200) <= 6 + && middle[2].abs_diff(30) <= 6, + "{middle:?}" + ); + } + + #[test] + fn a_null_cell_is_empty_in_the_table() { + let content = text_of(Kind::Json, r#"[{"a": null, "b": true}]"#); + assert_eq!(rendered(Form::JsonTable, &content), "a\tb\n\ttrue"); + assert_eq!( + rendered(Form::JsonTable, &text_of(Kind::Json, r#"{"k": null}"#)), + "k\t" + ); + } + + #[test] + fn files_are_offered_by_their_paths() { + let content = Content { + kind: Some(Kind::File), + paths: vec!["/tmp/uno.txt".into(), "/tmp/dos.txt".into()], + ..Default::default() + }; + assert_eq!(forms_for(&content), vec![Form::Path]); + assert_eq!(rendered(Form::Path, &content), "/tmp/uno.txt\n/tmp/dos.txt"); + } + + #[test] + fn a_form_that_does_not_apply_renders_nothing() { + let plain = text_of(Kind::Text, "hola"); + for form in [ + Form::Markdown, + Form::JsonPretty, + Form::ColorHex, + Form::LinkDomain, + Form::TokenClaims, + Form::ImageJpeg, + Form::ImageOcr, + ] { + assert_eq!(render(form, &plain), None, "{form:?}"); + } + } + + #[test] + fn html_from_a_word_processor_becomes_readable_markdown() { + let html = "x\ +

Informe

\ +

Un párrafo con negrita, cursiva y un \ + enlace.

\ +
  • uno
  • dos fuerte
\ +
  1. primero
  2. segundo
\ +

Fin & código x < y

\ + "; + assert_eq!( + markdown_of_html(html), + "## Informe\n\n\ + Un párrafo con **negrita**, *cursiva* y un [enlace](https://ejemplo.test).\n\n\ + - uno\n- dos **fuerte**\n\n\ + 1. primero\n2. segundo\n\n\ + Fin & código `x < y`" + ); + } + + #[test] + fn the_windows_clipboard_header_is_left_out_and_only_the_fragment_counts() { + let html = "Version:0.9\r\nStartHTML:0000000105\r\nEndHTML:0000000250\r\n\ +

solo esto

"; + assert_eq!(markdown_of_html(html), "solo **esto**"); + } + + #[test] + fn preformatted_blocks_keep_their_whitespace_and_nested_lists_indent() { + let html = "
fn main() {\n    hola\n}
  • a
    • b
"; + assert_eq!( + markdown_of_html(html), + "```\nfn main() {\n hola\n}\n```\n\n- a\n - b" + ); + } + + #[test] + fn quotes_images_rules_and_line_breaks() { + let html = "
dicho
\"foto\"
uno
dos"; + assert_eq!( + markdown_of_html(html), + "> dicho\n\n![foto](a.png)\n\n---\n\nuno\ndos" + ); + } + + #[test] + fn whitespace_between_tags_collapses_like_a_browser_would() { + let html = "

\n varias\n palabras juntas \n

\n

otro

"; + assert_eq!(markdown_of_html(html), "varias palabras **juntas**\n\notro"); + } + + #[test] + fn what_is_not_html_at_all_comes_out_as_text() { + assert_eq!(markdown_of_html("2 < 3 y 4 > 1"), "2 < 3 y 4 > 1"); + assert_eq!(markdown_of_html(""), ""); + assert_eq!(markdown_of_html("<"), "<"); + assert_eq!(markdown_of_html("

"), ""); + } + + #[test] + fn entities_of_every_shape_are_decoded_and_the_rest_left_alone() { + assert_eq!( + decode_entities("&<>"' "), + "&<>\"' " + ); + assert_eq!(decode_entities("ABC"), "ABC"); + assert_eq!( + decode_entities("&desconocida; & suelto &;"), + "&desconocida; & suelto &;" + ); + assert_eq!( + decode_entities("�"), + "�", + "fuera de Unicode" + ); + assert_eq!(decode_entities("áÑ€…"), "áÑ€…"); + assert_eq!(decode_entities("ýü"), "ýü"); + assert_eq!( + decode_entities("→"), + "→", + "las nombradas que no se conocen se dejan" + ); + } + + #[test] + fn a_script_or_style_is_skipped_until_its_own_closing_tag() { + assert_eq!(markdown_of_html("c"), "c"); + assert_eq!(markdown_of_html("z"), "z"); + assert_eq!( + markdown_of_html("tcuerpo"), + "cuerpo" + ); + } + + #[test] + fn headings_tables_and_lists_keep_their_distance_from_what_follows() { + assert_eq!(markdown_of_html("

t

x"), "# t\n\nx"); + assert_eq!( + markdown_of_html("
ab
"), + "| a | b |\n| --- | --- |" + ); + assert_eq!(markdown_of_html("a
  • b
"), "a\n\n- b"); + assert_eq!(markdown_of_html("
x
"), "```\nx\n```"); + assert_eq!(markdown_of_html("a b"), "**a** b"); + assert_eq!(markdown_of_html("vacío"), "vacío"); + assert_eq!(markdown_of_html("

x

"), "x"); + assert_eq!(markdown_of_html("
q
"), "> q"); + assert_eq!( + markdown_of_html("
x"), + "x", + "un salto al principio no deja hueco" + ); + assert_eq!(markdown_of_html("
  • a
"), "- a"); + assert_eq!(markdown_of_html("
  1. a
"), "1. a"); + assert_eq!(markdown_of_html("uno

dos"), "uno\ndos"); + assert_eq!(markdown_of_html(" x"), "![](a.png) x"); + assert_eq!(markdown_of_html("
a\n
b"), "```\na\n```\n\nb"); + } + + #[test] + fn a_multibyte_character_right_after_a_tag_does_not_panic() { + assert_eq!(markdown_of_html("

ñandú

"), "ñandú"); + assert_eq!(markdown_of_html("—€"), "**—**€"); + assert_eq!(markdown_of_html("ñ<ñ"), "ñ<ñ"); + } + + #[test] + fn an_angle_bracket_that_never_closes_is_text_and_costs_one_pass() { + let hostile = " b -->x"), "x"); + assert_eq!( + markdown_of_html("b\">t"), + "[t](x)" + ); + assert_eq!(markdown_of_html("