Skip to main content

syllabify_fr/
html.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2//! Rendu HTML avec balises `<span>` autour de chaque syllabe, destiné à
3//! l'intégration web (coloriage syllabique pédagogique type dyscolor.com).
4//!
5//! Conventions :
6//!
7//! - chaque syllabe → `<span class="syl syl-a">…</span>` ou `syl-b`, alternées
8//!   à l'intérieur de chaque mot (la première syllabe de chaque mot est toujours `syl-a`) ;
9//! - chaque mot est enveloppé par `<span class="word">…</span>` ;
10//! - le texte brut (espaces, ponctuation) est conservé et échappé ;
11//! - une liaison possible entre deux mots adjacents (séparés uniquement par
12//!   de l'espace) est matérialisée par `<span class="liaison" data-with="z"></span>`
13//!   inséré entre les deux mots, où `data-with` est la consonne de liaison
14//!   inférée de la dernière lettre du mot précédent.
15//!
16//! Le HTML produit est auto-suffisant : à la CSS du consommateur de définir
17//! les styles sur `.syl-a`, `.syl-b`, `.word`, `.liaison`.
18//!
19//! ```
20//! use syllabify_fr::{render_word_html, render_html};
21//!
22//! assert_eq!(
23//!     render_word_html("chocolat"),
24//!     r#"<span class="word"><span class="syl syl-a">cho</span><span class="syl syl-b">co</span><span class="syl syl-a">lat</span></span>"#
25//! );
26//!
27//! // Liaison détectée entre 'les' et 'hôtels'
28//! assert!(render_html("les hôtels").contains(r#"<span class="liaison" data-with="z""#));
29//! ```
30
31use crate::decoder::TextChunk;
32use crate::{liaison_possible, syllabify_text, syllables};
33
34/// Rend un mot unique en HTML avec syllabes enveloppées par des `<span>` alternés.
35///
36/// Le mot complet est enveloppé dans `<span class="word">…</span>`.
37/// Utilise le mode standard (pédagogique) — `homme` → `hom-me`.
38pub fn render_word_html(word: &str) -> String {
39    render_word_spans(&syllables(word))
40}
41
42/// Rend un texte complet en HTML.
43///
44/// Les homographes sont désambiguïsés selon le mot précédent (comme
45/// `syllabify_text`). Les liaisons possibles entre mots adjacents sont
46/// marquées par des spans `<span class="liaison" data-with="…">`.
47///
48/// Le texte brut entre les mots (espaces, ponctuation) est préservé et
49/// échappé HTML. Une liaison n'est émise que si l'intervalle entre deux
50/// mots est constitué *uniquement* d'espaces (pas de virgule, point, etc.).
51pub fn render_html(text: &str) -> String {
52    let chunks = syllabify_text(text);
53    let mut out = String::with_capacity(text.len() * 4);
54    let mut previous_word_raw: Option<String> = None;
55
56    for chunk in &chunks {
57        match chunk {
58            TextChunk::Raw(s) => {
59                // Si on voit autre chose que des espaces, le contexte de
60                // liaison avec le mot précédent est cassé.
61                if !s.chars().all(char::is_whitespace) {
62                    previous_word_raw = None;
63                }
64                out.push_str(&escape(s));
65            }
66            TextChunk::Word(sylls) => {
67                let word_raw: String = sylls.concat();
68                if let Some(prev) = &previous_word_raw {
69                    if liaison_possible(prev, &word_raw) {
70                        let consonant = liaison_consonant_for(prev);
71                        out.push_str(&format!(
72                            r#"<span class="liaison" data-with="{}"></span>"#,
73                            consonant
74                        ));
75                    }
76                }
77                out.push_str(&render_word_spans(sylls));
78                previous_word_raw = Some(word_raw);
79            }
80        }
81    }
82
83    out
84}
85
86fn render_word_spans(sylls: &[String]) -> String {
87    if sylls.iter().all(|s| s.is_empty()) {
88        return String::new();
89    }
90    let mut s = String::from(r#"<span class="word">"#);
91    for (i, syl) in sylls.iter().enumerate() {
92        let class = if i % 2 == 0 { "syl syl-a" } else { "syl syl-b" };
93        s.push_str(&format!(
94            r#"<span class="{}">{}</span>"#,
95            class,
96            escape(syl)
97        ));
98    }
99    s.push_str("</span>");
100    s
101}
102
103/// Consonne de liaison pour un mot précédent donné, inférée de sa dernière
104/// lettre. Retourne `"z"` par défaut (cas majoritaire : `-s`, `-x`, `-z` et
105/// déterminants pluriels), ce qui correspond à la majorité des mots de la
106/// liste `LIAISONS_AVAL`.
107fn liaison_consonant_for(prev: &str) -> &'static str {
108    let last = prev
109        .chars()
110        .rev()
111        .flat_map(|c| c.to_lowercase())
112        .next()
113        .unwrap_or(' ');
114    match last {
115        's' | 'x' | 'z' => "z",
116        'd' | 't' => "t",
117        'n' => "n",
118        'p' => "p",
119        'r' => "r",
120        'g' => "k",
121        _ => "z",
122    }
123}
124
125fn escape(s: &str) -> String {
126    let mut out = String::with_capacity(s.len());
127    for c in s.chars() {
128        match c {
129            '&' => out.push_str("&amp;"),
130            '<' => out.push_str("&lt;"),
131            '>' => out.push_str("&gt;"),
132            '"' => out.push_str("&quot;"),
133            '\'' => out.push_str("&#39;"),
134            _ => out.push(c),
135        }
136    }
137    out
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn mot_simple_3_syllabes() {
146        let html = render_word_html("chocolat");
147        assert_eq!(
148            html,
149            r#"<span class="word"><span class="syl syl-a">cho</span><span class="syl syl-b">co</span><span class="syl syl-a">lat</span></span>"#
150        );
151    }
152
153    #[test]
154    fn mot_alternance_demarre_a() {
155        assert!(render_word_html("famille")
156            .starts_with(r#"<span class="word"><span class="syl syl-a">fa</span>"#));
157    }
158
159    #[test]
160    fn texte_preserve_ponctuation() {
161        let html = render_html("le chat,");
162        assert!(html.contains(r#">le</span>"#));
163        assert!(html.contains(" "));
164        assert!(html.ends_with(","));
165    }
166
167    #[test]
168    fn liaison_les_hotels_emet_span() {
169        let html = render_html("les hôtels");
170        assert!(
171            html.contains(r#"<span class="liaison" data-with="z"></span>"#),
172            "liaison 'z' attendue, got: {}",
173            html
174        );
175        // Ordre : mot1, espace, span liaison, mot2
176        let pos_first_word = html.find("les").unwrap();
177        let pos_liaison = html.find(r#"class="liaison""#).unwrap();
178        let pos_second_word = html.find("ô").unwrap();
179        assert!(pos_first_word < pos_liaison);
180        assert!(pos_liaison < pos_second_word);
181    }
182
183    #[test]
184    fn liaison_absente_h_aspire() {
185        // 'les héros' : h aspiré, pas de liaison
186        let html = render_html("les héros");
187        assert!(!html.contains(r#"class="liaison""#));
188    }
189
190    #[test]
191    fn liaison_absente_consonne_initiale() {
192        let html = render_html("les chats");
193        assert!(!html.contains(r#"class="liaison""#));
194    }
195
196    #[test]
197    fn liaison_consonne_t_pour_tout() {
198        // 'tout' est dans LIAISONS_AVAL et finit par 't' → liaison en 't'
199        let html = render_html("tout ami");
200        assert!(html.contains(r#"data-with="t""#), "got: {}", html);
201    }
202
203    #[test]
204    fn liaison_consonne_n_pour_en() {
205        // 'en' → liaison en 'n' (denasalisation — attestée en français)
206        let html = render_html("en automne");
207        assert!(html.contains(r#"data-with="n""#), "got: {}", html);
208    }
209
210    #[test]
211    fn liaison_bloquee_par_virgule() {
212        // 'les, hôtels' : virgule entre les deux → pas de liaison
213        let html = render_html("les, hôtels");
214        assert!(!html.contains(r#"class="liaison""#), "got: {}", html);
215    }
216
217    #[test]
218    fn homographes_contexte_respecte() {
219        // syllabify_text fait la désambiguïsation ; on vérifie que le rendu
220        // l'honore bien : 'couvent' après 'le' = nom (cou-vent), après 'elles' = verbe
221        let html_nom = render_html("le couvent");
222        let html_verbe = render_html("elles couvent");
223        // nom : deux syllabes cou/vent
224        assert!(
225            html_nom.contains(r#">cou</span><span class="syl syl-b">vent</span>"#),
226            "got: {}",
227            html_nom
228        );
229        // verbe : la 2e syllabe 'vent' est prononcée muet, mais la graphie reste 'vent'
230        // (la différence est phonétique, pas graphique — donc même rendu textuel).
231        assert!(html_verbe.contains(">cou</span>"));
232    }
233
234    #[test]
235    fn html_echappe_caracteres_speciaux() {
236        // L'input contient un caractère à échapper dans du texte brut
237        let html = render_html("a < b");
238        assert!(html.contains("&lt;"));
239    }
240
241    #[test]
242    fn texte_vide() {
243        assert_eq!(render_html(""), "");
244    }
245
246    #[test]
247    fn mot_vide_ne_crash_pas() {
248        // syllables("") retourne [] → render_word_html retourne ""
249        assert_eq!(render_word_html(""), "");
250    }
251}