@@ -62,7 +62,32 @@ fn decode_data_uri(uri: &str) -> Option<Vec<u8>> {
6262}
6363
6464fn parse_hex ( s : & str ) -> Option < [ f32 ; 4 ] > {
65- let h = s. trim ( ) . trim_start_matches ( '#' ) ;
65+ // Le contrat accepte du CSS, pas seulement de l'hex : la bridge des captions produit du
66+ // `rgba(r, g, b, a)` (l'inspector stocke couleur + opacité séparément, et `captionBackgroundCss`
67+ // les recombine en rgba pour la preview) et les stops de gradient arrivent aussi sous cette
68+ // forme. `transparent` est un cas particulier documenté : alpha 0, pas de plaque. Tout le
69+ // reste tombe sur None → l'appelant applique son fallback (alpha 0 pour un fond, alpha 1
70+ // pour un texte, etc.) — la même sémantique qu'avant l'ajout du parseur rgba.
71+ let trimmed = s. trim ( ) ;
72+ if trimmed. eq_ignore_ascii_case ( "transparent" ) {
73+ return Some ( [ 0.0 , 0.0 , 0.0 , 0.0 ] ) ;
74+ }
75+ // CSS Color 4 fait de `rgb()` et `rgba()` des synonymes : les deux acceptent 3 ou 4
76+ // composantes. On les traite donc par le même chemin plutôt que d'imposer une arité par
77+ // nom — refuser `rgba(0, 0, 0)` ne « signalerait » rien d'utile, ça retomberait sur le
78+ // fallback de l'appelant, c'est-à-dire une plaque invisible : exactement le bug #178.
79+ if let Some ( inner) =
80+ strip_color_fn ( trimmed, "rgba" ) . or_else ( || strip_color_fn ( trimmed, "rgb" ) )
81+ {
82+ return parse_rgb_components ( inner) ;
83+ }
84+ let h = trimmed. trim_start_matches ( '#' ) ;
85+ // Un corps hex est ASCII par définition, et les découpes par octet ci-dessous (`h[i..=i]`,
86+ // `h[0..2]`…) paniqueraient au milieu d'un caractère multi-octets qui ferait pile 3 ou 6
87+ // octets (`éa`, `€€`). On refuse avant de découper.
88+ if !h. is_ascii ( ) {
89+ return None ;
90+ }
6691 let ( r, g, b) = match h. len ( ) {
6792 3 => {
6893 let d = |i : usize | u8:: from_str_radix ( & h[ i..=i] , 16 ) . ok ( ) . map ( |v| v * 17 ) ;
@@ -78,6 +103,58 @@ fn parse_hex(s: &str) -> Option<[f32; 4]> {
78103 Some ( [ r as f32 / 255.0 , g as f32 / 255.0 , b as f32 / 255.0 , 1.0 ] )
79104}
80105
106+ /// `rgba(0, 0, 0, 0.55)` → `"0, 0, 0, 0.55"` (le contenu entre les parenthèses), None si
107+ /// l'enveloppe n'est pas de la forme `fn(...)`. Tolère les espaces et les tabs, refuse les
108+ /// virgules finales et les arguments vides — le gradient parser a déjà démontré que la couche
109+ /// application produit des chaînes propres, donc rester strict ici évite d'avaler des CSS
110+ /// tordus qu'on ne maîtrise pas. La casse du préfixe est libre (`RGBA(...)` est valide) parce
111+ /// que CSS le permet.
112+ fn strip_color_fn < ' a > ( s : & ' a str , name : & str ) -> Option < & ' a str > {
113+ // `get` rend None si `name.len()` n'est pas une frontière de caractère : c'est ce qui rend
114+ // le slice `s[..name.len()]` juste en dessous sûr par construction. Un `&s[..n]` direct
115+ // paniquerait au milieu d'un caractère multi-octets (`#ab€cd` coupe dans le `€`), et une
116+ // panique traverserait le pont N-API au lieu de retomber sur le fallback de l'appelant —
117+ // le contraire de ce que ce parseur promet.
118+ let after_name = s. get ( name. len ( ) ..) ?;
119+ if !s[ ..name. len ( ) ] . eq_ignore_ascii_case ( name) {
120+ return None ;
121+ }
122+ let inner = after_name. strip_prefix ( '(' ) ?. strip_suffix ( ')' ) ?. trim ( ) ;
123+ if inner. is_empty ( ) {
124+ return None ;
125+ }
126+ Some ( inner)
127+ }
128+
129+ /// `"r, g, b"` ou `"r, g, b, a"` (floats 0..255 pour r/g/b, 0..1 pour a) → `[r, g, b, a]` en
130+ /// 0..1, l'alpha valant 1 (opaque) quand elle est absente. Toute autre arité → None. Tolère
131+ /// les espaces autour des virgules, pas les pourcentages : le gradient parser n'envoie pas de
132+ /// `rgb(50%, …)` et les couches UI qui le font n'arrivent pas ici (les couleurs wallpaper
133+ /// passent par une autre route, cf. `parseWallpaper`).
134+ fn parse_rgb_components ( s : & str ) -> Option < [ f32 ; 4 ] > {
135+ let parts: Vec < & str > = s. split ( ',' ) . map ( str:: trim) . collect ( ) ;
136+ let ( rgb, alpha) = match parts. as_slice ( ) {
137+ [ r, g, b] => ( [ r, g, b] , 1.0 ) ,
138+ // L'alpha est déjà sur [0..1] par convention (`rgba(...,0.55)`, pas `rgba(...,55)`).
139+ [ r, g, b, a] => ( [ r, g, b] , parse_color_channel ( a, 1.0 ) ?) ,
140+ _ => return None ,
141+ } ;
142+ Some ( [
143+ parse_color_channel ( rgb[ 0 ] , 255.0 ) ?,
144+ parse_color_channel ( rgb[ 1 ] , 255.0 ) ?,
145+ parse_color_channel ( rgb[ 2 ] , 255.0 ) ?,
146+ alpha,
147+ ] )
148+ }
149+
150+ fn parse_color_channel ( raw : & str , max : f32 ) -> Option < f32 > {
151+ let n: f32 = raw. parse ( ) . ok ( ) ?;
152+ if !n. is_finite ( ) || n < 0.0 || n > max {
153+ return None ;
154+ }
155+ Some ( n / max)
156+ }
157+
81158/// Rect source après crop puis zoom, dans les UV de la texture D3D. `u_max`/`v_max`
82159/// excluent le padding NV12 ; le crop reste donc exprimé dans le frame visible (0..1),
83160/// comme `VirtualPreview.cropVideoStyle`, puis le focus du zoom est remappé dans ce crop.
@@ -3184,6 +3261,89 @@ mod tests {
31843261 assert_eq ! ( decode_data_uri( "data:image/png;base64,SGkh" ) . unwrap( ) , b"Hi!" . to_vec( ) ) ;
31853262 }
31863263
3264+ /// L'inspector stocke les couleurs de caption comme `couleur_hex` + `opacité` puis la
3265+ /// bridge JS recombine en `rgba(r, g, b, a)` pour la preview. Le natif doit rendre la même
3266+ /// plaque (couleur et opacité) — sinon le calque disparaît silencieusement et la caption
3267+ /// n'apparaît qu'en texte brut dans l'export. C'était exactement le bug de l'issue #178.
3268+ #[ test]
3269+ fn parse_hex_understands_rgba_caption_backgrounds ( ) {
3270+ let parsed = parse_hex ( "rgba(0, 0, 0, 0.55)" ) . expect ( "rgba doit parser" ) ;
3271+ assert ! ( ( parsed[ 3 ] - 0.55 ) . abs( ) < 1e-6 , "alpha 0.55 transmise, pas tombée à 0" ) ;
3272+ assert_eq ! ( [ parsed[ 0 ] , parsed[ 1 ] , parsed[ 2 ] ] , [ 0.0 , 0.0 , 0.0 ] ) ;
3273+ }
3274+
3275+ /// `rgb(...)` sans alpha est sémantiquement `rgba(..., 1)` — il faut le supporter pour
3276+ /// qu'un inspector qui n'expose pas d'opacité n'écrive pas un fond invisible.
3277+ #[ test]
3278+ fn parse_hex_treats_rgb_as_opaque ( ) {
3279+ let parsed = parse_hex ( "rgb(255, 128, 0)" ) . expect ( "rgb doit parser" ) ;
3280+ assert_eq ! ( parsed, [ 1.0 , 128.0 / 255.0 , 0.0 , 1.0 ] ) ;
3281+ }
3282+
3283+ /// Le cas "transparent" est documenté dans le code d'appel : on garde la sémantique
3284+ /// historique (alpha 0) — la plaque est sautée côté rastérisation, ce qui est exactement ce
3285+ /// que veut le CSS. Le nouveau parseur ne doit pas le casser.
3286+ #[ test]
3287+ fn parse_hex_keeps_transparent_at_alpha_zero ( ) {
3288+ assert_eq ! ( parse_hex( "transparent" ) , Some ( [ 0.0 , 0.0 , 0.0 , 0.0 ] ) ) ;
3289+ // La casse ne doit pas non plus casser : CSS autorise `TRANSPARENT` en théorie, et
3290+ // refuse une chaîne qui ressemble à un rgba mal formé.
3291+ assert_eq ! ( parse_hex( "Transparent" ) , Some ( [ 0.0 , 0.0 , 0.0 , 0.0 ] ) ) ;
3292+ assert_eq ! ( parse_hex( "rgba(0, 0, 0, 0)" ) , Some ( [ 0.0 , 0.0 , 0.0 , 0.0 ] ) ) ;
3293+ }
3294+
3295+ /// Le contrat historique `#rrggbb` / `rrggbb` ne doit pas régresser : les annotations
3296+ /// normales (saisies via `ColorField`) ne passent que par ce chemin, et leurs snapshots
3297+ /// ne pardonneraient pas un changement d'alpha implicite.
3298+ #[ test]
3299+ fn parse_hex_still_understands_hex_colours ( ) {
3300+ assert_eq ! ( parse_hex( "#fff" ) , Some ( [ 1.0 , 1.0 , 1.0 , 1.0 ] ) ) ;
3301+ assert_eq ! ( parse_hex( "#000000" ) , Some ( [ 0.0 , 0.0 , 0.0 , 1.0 ] ) ) ;
3302+ assert_eq ! (
3303+ parse_hex( "ff8800" ) ,
3304+ Some ( [ 1.0 , 136.0 / 255.0 , 0.0 , 1.0 ] )
3305+ ) ;
3306+ }
3307+
3308+ /// Hors-format (channel > 255, chaîne vide, named color) → None → l'appelant retombe sur
3309+ /// son fallback. C'est la même politique qu'avant l'ajout du parseur rgba, on la garde
3310+ /// explicite pour qu'elle ne dérive pas.
3311+ #[ test]
3312+ fn parse_hex_rejects_malformed_colours ( ) {
3313+ assert_eq ! ( parse_hex( "" ) , None ) ;
3314+ assert_eq ! ( parse_hex( "not-a-color" ) , None ) ;
3315+ assert_eq ! ( parse_hex( "rgba(256, 0, 0, 1)" ) , None ) ; // canal >255
3316+ assert_eq ! ( parse_hex( "rgba(0, 0, 0, 1.5)" ) , None ) ; // alpha >1
3317+ assert_eq ! ( parse_hex( "rgba(0, 0, 0, 0.5, 1)" ) , None ) ; // 5 composantes
3318+ assert_eq ! ( parse_hex( "rgb(0, 0)" ) , None ) ; // 2 composantes
3319+ }
3320+
3321+ /// CSS Color 4 : `rgb()` et `rgba()` sont synonymes, les deux prennent 3 ou 4 composantes.
3322+ /// Une couleur bien formée ne doit pas finir sur le fallback de l'appelant — pour un fond
3323+ /// c'est alpha 0, donc une plaque invisible, soit très exactement le symptôme de #178.
3324+ #[ test]
3325+ fn parse_hex_accepts_both_arities_on_both_names ( ) {
3326+ assert_eq ! ( parse_hex( "rgba(0, 0, 0)" ) , Some ( [ 0.0 , 0.0 , 0.0 , 1.0 ] ) ) ;
3327+ assert_eq ! ( parse_hex( "rgb(0, 0, 0, 0.5)" ) , Some ( [ 0.0 , 0.0 , 0.0 , 0.5 ] ) ) ;
3328+ }
3329+
3330+ /// Une couleur non-ASCII doit être refusée, pas paniquer : `strip_color_fn` découpait
3331+ /// `s[..3]` / `s[..4]` sans vérifier la frontière de caractère, donc `#ab€cd` (le `€` occupe
3332+ /// les octets 3..6) tuait le process au lieu de retomber sur le fallback. `parseWallpaper`
3333+ /// laisse passer n'importe quelle chaîne préfixée `#` jusqu'ici, une panique côté natif
3334+ /// traverserait le pont N-API et emporterait l'export.
3335+ #[ test]
3336+ fn parse_hex_refuses_non_ascii_without_panicking ( ) {
3337+ assert_eq ! ( parse_hex( "#ab€cd" ) , None ) ;
3338+ assert_eq ! ( parse_hex( "rg€(0, 0, 0)" ) , None ) ;
3339+ assert_eq ! ( parse_hex( "é" ) , None ) ;
3340+ assert_eq ! ( parse_hex( "🎨🎨" ) , None ) ;
3341+ // Le chemin hex découpe par octet sur les longueurs 3 et 6 : `éa` fait 3 octets et
3342+ // `€€` en fait 6, donc les deux tombaient pile sur une découpe intra-caractère.
3343+ assert_eq ! ( parse_hex( "éa" ) , None ) ;
3344+ assert_eq ! ( parse_hex( "€€" ) , None ) ;
3345+ }
3346+
31873347 #[ test]
31883348 fn ignores_padding_and_line_breaks_inside_the_payload ( ) {
31893349 // Un URI replié ou paddé doit décoder à l'identique : les caractères hors alphabet sont
0 commit comments