Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions crates/compositor/src/compositor_linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1497,6 +1497,9 @@ impl Compositor {
italic: text.font_style == "italic",
underline: text.text_decoration == "underline",
align: text.text_align.clone(),
// Absent = "center", le comportement historique : les
// annotations ne changent pas d'un pixel.
valign: text.vertical_align.clone().unwrap_or_default(),
box_px: [
quad_px[0].round().max(1.0) as u32,
quad_px[1].round().max(1.0) as u32,
Expand Down
3 changes: 3 additions & 0 deletions crates/compositor/src/compositor_macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1146,6 +1146,9 @@ impl Compositor {
italic: text.font_style == "italic",
underline: text.text_decoration == "underline",
align: text.text_align.clone(),
// Absent = "center", le comportement historique : les
// annotations ne changent pas d'un pixel.
valign: text.vertical_align.clone().unwrap_or_default(),
box_px: [quad_px[0].round() as u32, quad_px[1].round() as u32],
};
let key = spec.cache_key();
Expand Down
3 changes: 3 additions & 0 deletions crates/compositor/src/compositor_windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1777,6 +1777,9 @@ impl Compositor {
italic: text.font_style == "italic",
underline: text.text_decoration == "underline",
align: text.text_align.clone(),
// Absent = "center", le comportement historique : les
// annotations ne changent pas d'un pixel.
valign: text.vertical_align.clone().unwrap_or_default(),
box_px: [quad_px[0].round() as u32, quad_px[1].round() as u32],
};
let key = spec.cache_key();
Expand Down
12 changes: 12 additions & 0 deletions crates/compositor/src/scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,18 @@ pub struct SceneAnnotationText {
pub font_style: String,
pub text_decoration: String,
pub text_align: String,
/// Quelle arête du bloc de texte est épinglée à sa boîte : `"top"` / `"center"`
/// / `"bottom"`. Absent = `"center"`, le comportement historique — les
/// annotations n'émettent jamais la clé et ne bougent donc pas d'un pixel.
/// Les sous-titres l'émettent pour que l'arête ancrée tienne quand le texte
/// gagne une ligne (un bloc centré voit ses deux arêtes se déplacer).
///
/// `Option<String>` et pas une enum, pour la même raison que `space` : serde
/// rejette une variante d'unité inconnue, donc une valeur future ferait
/// échouer `Scene::from_json` *en entier* sur un binaire plus ancien, au lieu
/// de coûter un seul sous-titre mal placé.
#[serde(default)]
pub vertical_align: Option<String>,
#[serde(default)]
pub animation: Option<String>,
}
Expand Down
125 changes: 124 additions & 1 deletion crates/compositor/src/text_linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ pub struct TextSpec {
pub underline: bool,
/// "left" | "center" | "right".
pub align: String,
/// "top" | "center" | "bottom" -- quelle arete du bloc de texte est epinglee
/// a la boite. "center" est le comportement historique (et celui des
/// annotations, qui reproduisent `alignItems: center` de l'overlay web) ; les
/// sous-titres passent "bottom" ou "top" pour que l'arete ancree ne bouge pas
/// quand le texte gagne une ligne.
pub valign: String,
/// Taille de la boite en px de sortie.
pub box_px: [u32; 2],
}
Expand All @@ -61,6 +67,10 @@ impl TextSpec {
}
mix(&[self.bold as u8, self.italic as u8, self.underline as u8]);
mix(self.align.as_bytes());
// Juste apres `align`, memes octets et meme position que sur les deux
// autres backends : deux specs ne differant que par l'alignement vertical
// rendraient sinon les pixels l'une de l'autre depuis le cache.
mix(self.valign.as_bytes());
mix(&self.box_px[0].to_le_bytes());
mix(&self.box_px[1].to_le_bytes());
h
Expand Down Expand Up @@ -194,7 +204,28 @@ impl TextRasterizer {
.fold(0.0f32, f32::max);
// `max(0)` : un texte plus haut que sa boite reste ancre en haut plutot
// que de sortir par le dessus, ou il serait entierement rogne.
let y_offset = (((h as f32) - text_h) * 0.5).max(0.0).round() as i32;
//
// ANCRAGE. `center` est le comportement historique, et reste celui des
// annotations. Les sous-titres epinglent une arete : c'est la seule facon
// que l'arete ancree ne bouge pas quand le texte gagne une ligne, parce
// qu'un bloc centre voit ses DEUX aretes se deplacer.
//
// `anchor_pad` reserve la marge de la plaque DU COTE ANCRE. Sans elle, coller
// le bloc de texte au bord laisse la plaque poser toute sa marge du cote
// oppose et zero du cote ancre : le fond epouse alors le bas des lettres au
// pixel pres tout en respirant deux fois trop au-dessus. Ce qui doit toucher
// le bord de la boite est la PLAQUE, pas les glyphes — c'est elle que le
// viewer voit. Sans plaque, il n'y a rien a reserver.
let has_plate = spec.background[3] > 0.0;
let anchor_pad = if has_plate { pad_y } else { 0.0 };
let slack_y = ((h as f32) - text_h).max(0.0);
let y_offset = match spec.valign.as_str() {
"top" | "start" => anchor_pad,
"bottom" | "end" => slack_y - anchor_pad,
_ => slack_y * 0.5,
}
.clamp(0.0, slack_y)
.round() as i32;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// LA PLAQUE EPOUSE LE BLOC, PAS LA BOITE. Miroir de
// `text_macos::block_layout` (en coordonnees descendantes ici, CoreText
Expand Down Expand Up @@ -385,6 +416,7 @@ mod tests {
italic: false,
underline: false,
align: align.to_owned(),
valign: "center".to_owned(),
box_px: [400, 200],
}
}
Expand Down Expand Up @@ -566,6 +598,97 @@ mod tests {
);
}

#[test]
fn the_anchored_edge_holds_still_when_the_text_gains_a_line() {
// L'INVARIANT de la refonte du placement des sous-titres, en une
// assertion — et celle que l'ancienne architecture ne pouvait pas ecrire.
//
// Un bloc centre voit ses DEUX aretes bouger quand il grandit : c'est
// exactement pourquoi elargir la bande deplacait verticalement le
// sous-titre. Ancre en bas, l'arete basse ne doit pas bouger d'un pixel,
// que le texte tienne sur une ligne ou en reclame trois.
let raster = TextRasterizer::new().expect("rasterizer");
let (w, h) = (400usize, 200usize);
let long = "un texte assez long pour devoir se replier sur plusieurs lignes";

// On mesure la PLAQUE, pas le dernier pixel d'encre. La plaque epouse la boite
// de lignes posee par cosmic-text : c'est exactement ce que ce code epingle et
// ce que le compositeur dessine. Le bas de l'ENCRE, lui, depend des jambages du
// contenu — "Hx" n'en a aucun, "replier" en a — donc il descend plus bas a
// ancrage identique. Ancrer la boite de lignes plutot que l'encre est le
// comportement typographique attendu partout, et c'est la premiere version de
// ce test qui avait tort : elle comparait 184 a 199 et appelait ca une derive.
let plate_of = |valign: &str, content: &str| {
let mut s = spec(content, "center");
s.valign = valign.to_owned();
let atlas = raster.build_atlas(&s).expect("atlas");
let [_, py, _, ph] = atlas.plate;
let rows = ink_rows(&atlas.pixels, w, 0, w);
assert!(!rows.is_empty(), "aucune encre pour {valign:?}");
(py, py + ph, rows[0], *rows.last().unwrap())
};

let (short_top_edge, short_bottom, _, _) = plate_of("bottom", "Hx");
let (long_top_edge, long_bottom, _, _) = plate_of("bottom", long);
assert!(
(short_bottom - long_bottom).abs() < 1.0,
"ancrage bas : l'arete basse a bouge de {short_bottom} a {long_bottom} \
en passant d'une ligne a plusieurs"
);

// Et le miroir, pour que « haut » ne soit pas juste « pas bas ».
let (short_top, _, _, _) = plate_of("top", "Hx");
let (long_top, _, _, _) = plate_of("top", long);
assert!(
(short_top - long_top).abs() < 1.0,
"ancrage haut : l'arete haute a bouge de {short_top} a {long_top}"
);

// Le texte long doit vraiment se replier, sinon les deux assertions ci-dessus
// passeraient sur deux rendus identiques et ne prouveraient rien.
assert!(
(long_bottom - long_top_edge) > (short_bottom - short_top_edge) + 1.0,
"le texte « long » ne s'est pas replie : le test ne prouve rien"
);

// Les trois ancrages doivent poser la plaque a trois endroits differents,
// sinon `valign` n'est pas applique du tout.
let (top_y, _, _, _) = plate_of("top", "Hx");
let (ctr_y, _, _, _) = plate_of("center", "Hx");
let (bot_y, _, _, _) = plate_of("bottom", "Hx");
assert!(
top_y < ctr_y && ctr_y < bot_y,
"les trois ancrages ne se distinguent pas : haut={top_y} centre={ctr_y} bas={bot_y}"
);

// Enfin, l'encre reste dans la plaque qui la porte, et la plaque dans la boite :
// c'est ce qui relie la boite mesuree ci-dessus a ce que le viewer voit.
//
// Tolerance `pad_y`, la meme que `the_plate_hugs_the_text_instead_of_filling_the_box`
// plus haut : l'encre est bornee par la boite de LIGNES, et un glyphe peut deborder
// legerement la sienne (jambages, accents) sans que rien ne soit casse. C'est
// exactement l'hypothese que la premiere version de ce test avait fausse.
for valign in ["top", "bottom"] {
let (py, pb, ink_top, ink_bottom) = plate_of(valign, long);
let (_, pad_y) = crate::text_plate::padding(40.0);
assert!(
(ink_top as f32) >= py - pad_y && (ink_bottom as f32) <= pb + pad_y,
"{valign} : l'encre ({ink_top}..{ink_bottom}) sort de la plaque ({py}..{pb})"
);
assert!(pb <= (h as f32) + 0.01, "{valign} : la plaque sort de la boite");
}
}

#[test]
fn the_vertical_anchor_changes_the_cache_key() {
// Le piege du cache : la cle est partagee entre plateformes, et deux specs
// ne differant que par `valign` rendraient les pixels l'une de l'autre si
// le champ n'y entrait pas.
let mut bottom = spec("Hx", "center");
bottom.valign = "bottom".to_owned();
assert_ne!(bottom.cache_key(), spec("Hx", "center").cache_key());
}

#[test]
fn centering_moves_the_ink_off_the_left_edge() {
// `spec.align` n'etait jamais applique : tout sortait ferre a gauche
Expand Down
87 changes: 83 additions & 4 deletions crates/compositor/src/text_macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ pub struct TextSpec {
pub underline: bool,
/// "left" | "center" | "right".
pub align: String,
/// "top" | "center" | "bottom" — quelle arête du bloc est épinglée à la boîte.
/// "center" est le comportement historique (et celui des annotations) ; les
/// sous-titres passent "bottom" ou "top" pour que l'arête ancrée ne bouge pas
/// quand le texte gagne une ligne.
pub valign: String,
/// Taille de la boîte en px de sortie — la mise en page en dépend (retours à la ligne).
pub box_px: [u32; 2],
}
Expand Down Expand Up @@ -79,6 +84,10 @@ impl TextSpec {
}
mix(&[self.bold as u8, self.italic as u8, self.underline as u8]);
mix(self.align.as_bytes());
// Juste après `align`, mêmes octets et même position que sur les deux
// autres backends : deux specs ne différant que par l'alignement vertical
// rendraient sinon les pixels l'une de l'autre depuis le cache.
mix(self.valign.as_bytes());
mix(&self.box_px[0].to_le_bytes());
mix(&self.box_px[1].to_le_bytes());
h
Expand Down Expand Up @@ -308,6 +317,11 @@ fn block_layout(
text_w: CGFloat,
text_h: CGFloat,
align: u8,
valign: &str,
// Une plaque de fond est-elle dessinée ? Elle décide de la marge à réserver du côté
// ancré — voir `anchor_pad` plus bas. (Commentaire ordinaire et pas `///` : rustc
// refuse un doc-comment sur un paramètre.)
has_plate: bool,
font_px: CGFloat,
) -> (CGRect, CGRect) {
let (pad_x, pad_y) = plate_padding(font_px);
Expand All @@ -317,7 +331,24 @@ fn block_layout(
// la mesure. On l'étend d'un pixel vers le BAS — donc en abaissant l'origine `y`, pas
// en montant le sommet — pour que le haut du texte ne bouge pas d'un poil.
const GUARD: CGFloat = 1.0;
let top = ((box_h - text_h) * 0.5).max(0.0);
// ANCRAGE. `center` reste le comportement historique (et celui des annotations,
// qui reproduisent `alignItems: center` de l'overlay web). Les sous-titres
// épinglent une arête : un bloc centré voit ses DEUX arêtes bouger quand il
// gagne une ligne, ce qui déplaçait le sous-titre. `top` est ici une distance
// depuis le HAUT de la boîte, en coordonnées descendantes.
// `anchor_pad` réserve la marge de la plaque DU CÔTÉ ANCRÉ. Sans elle, coller le
// bloc de texte au bord laisse la plaque poser toute sa marge du côté opposé et
// zéro du côté ancré : le fond épouse le bas des lettres au pixel près tout en
// respirant deux fois trop au-dessus. Ce qui doit toucher le bord de la boîte est
// la PLAQUE, pas les glyphes. Sans plaque, il n'y a rien à réserver.
let anchor_pad = if has_plate { pad_y } else { 0.0 };
let slack_y = (box_h - text_h).max(0.0);
let top = match valign {
"top" | "start" => anchor_pad,
"bottom" | "end" => slack_y - anchor_pad,
_ => slack_y * 0.5,
}
.clamp(0.0, slack_y);
let frame_x = (box_w - avail_w) * 0.5;
let frame = CGRect {
origin: CGPoint {
Expand Down Expand Up @@ -593,7 +624,16 @@ impl TextRasterizer {
let text_h = measured.height.ceil().max(0.0);

let (frame_rect, plate_rect) =
block_layout(box_w, box_h, text_w, text_h, alignment, font_px);
block_layout(
box_w,
box_h,
text_w,
text_h,
alignment,
&spec.valign,
spec.background[3] > 0.0,
font_px,
);

// --- plaque de fond, sous le texte ---
if spec.background[3] > 0.0 && plate_rect.size.width > 0.0 && plate_rect.size.height > 0.0
Expand Down Expand Up @@ -651,6 +691,7 @@ mod tests {
italic: false,
underline: false,
align: "center".into(),
valign: "center".into(),
box_px: [256, 256],
}
}
Expand Down Expand Up @@ -828,7 +869,7 @@ mod tests {
/// Géométrie pure — pas de GPU, pas de CoreText.
#[test]
fn block_layout_centres_the_frame_and_sizes_the_plate() {
let (frame, plate) = block_layout(1536.0, 238.0, 500.0, 56.0, 2, 48.0);
let (frame, plate) = block_layout(1536.0, 238.0, 500.0, 56.0, 2, "center", true, 48.0);
// Cadre centré : autant de vide au-dessus qu'en dessous (repère CG, y vers le haut).
let above = 238.0 - (frame.origin.y + frame.size.height);
let below = frame.origin.y;
Expand All @@ -843,11 +884,49 @@ mod tests {
fn block_layout_never_lets_the_plate_leave_the_box() {
for align in [0u8, 1, 2] {
// Bloc plus large et plus haut que la boîte : la plaque doit se contenter d'elle.
let (_, plate) = block_layout(200.0, 60.0, 400.0, 200.0, align, 48.0);
let (_, plate) = block_layout(200.0, 60.0, 400.0, 200.0, align, "center", true, 48.0);
assert!(plate.origin.x >= 0.0, "align={align} : x={}", plate.origin.x);
assert!(plate.origin.y >= 0.0, "align={align} : y={}", plate.origin.y);
assert!(plate.origin.x + plate.size.width <= 200.0 + 0.01, "align={align}");
assert!(plate.origin.y + plate.size.height <= 60.0 + 0.01, "align={align}");
}
}

/// L'invariant de la refonte du placement des sous-titres, en géométrie pure.
/// Un bloc centré voit ses DEUX arêtes bouger quand il grandit ; ancré, l'arête
/// ancrée ne bouge pas. Repère CoreGraphics : `y` monte.
#[test]
fn block_layout_pins_the_anchored_edge_whatever_the_block_height() {
let (box_w, box_h) = (1536.0, 238.0);
let edges = |valign: &str, text_h: f64| {
let (frame, _) = block_layout(box_w, box_h, 500.0, text_h, 2, valign, true, 48.0);
// (bas, haut) en distance depuis le bas de la boîte.
(frame.origin.y, frame.origin.y + frame.size.height)
};

// Ancrage bas : l'arête basse est la même à une et à trois lignes.
let (one_bottom, _) = edges("bottom", 56.0);
let (three_bottom, _) = edges("bottom", 168.0);
assert!(
(one_bottom - three_bottom).abs() < 0.01,
"ancrage bas : l'arête basse a bougé de {one_bottom} à {three_bottom}"
);

// Ancrage haut : l'arête haute est la même.
let (_, one_top) = edges("top", 56.0);
let (_, three_top) = edges("top", 168.0);
assert!(
(one_top - three_top).abs() < 0.01,
"ancrage haut : l'arête haute a bougé de {one_top} à {three_top}"
);

// Et le centrage, lui, fait bien bouger les deux — c'est le comportement
// historique qu'on préserve pour les annotations.
let (c1_bottom, c1_top) = edges("center", 56.0);
let (c3_bottom, c3_top) = edges("center", 168.0);
assert!(
(c1_bottom - c3_bottom).abs() > 1.0 && (c1_top - c3_top).abs() > 1.0,
"le centrage devrait déplacer les deux arêtes"
);
}
}
Loading
Loading