-
-
Notifications
You must be signed in to change notification settings - Fork 221
Expand file tree
/
Copy pathtext_windows.rs
More file actions
554 lines (521 loc) · 25.7 KB
/
Copy pathtext_windows.rs
File metadata and controls
554 lines (521 loc) · 25.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
//! Rastérisation du texte des annotations, via DirectWrite (mise en page) et Direct2D (dessin)
//! sur une surface DXGI partagée avec D3D11.
//!
//! Pourquoi DirectWrite et pas un rasterizer de glyphes maison : l'app expédie en 13 langues,
//! dont l'arabe, et le texte d'une annotation est saisi librement. Le façonnage (ligatures,
//! bidirectionnel, CJK, sélection de police de repli) est un travail que DirectWrite fait
//! correctement et qu'on ne réécrira pas. Le device D3D11 est déjà créé avec
//! `D3D11_CREATE_DEVICE_BGRA_SUPPORT` (cf. `d3d.rs`, dont le commentaire anticipait cet usage),
//! donc l'interop ne coûte aucun changement de pipeline.
//!
//! Pourquoi ça ne coûte rien par frame : le texte d'une annotation est un contenu STATIQUE. On le
//! rastérise une fois dans une texture, et la boucle de rendu ne fait plus qu'un quad texturé de
//! plus. Le travail cher — façonnage, mise en page, rendu des glyphes — sort du chemin chaud, et
//! le cache est invalidé sur le contenu, le style et la taille de boîte, JAMAIS sur la
//! transformation : déplacer, redimensionner ou animer une annotation n'est pas une raison de
//! re-rastériser (c'est l'affaire du vertex shader).
use anyhow::{bail, Result};
use std::os::windows::ffi::OsStrExt;
use std::path::PathBuf;
use windows::core::Interface;
use windows::Win32::Graphics::Direct2D::Common::{
D2D1_ALPHA_MODE_PREMULTIPLIED, D2D1_COLOR_F, D2D1_PIXEL_FORMAT, D2D_POINT_2F, D2D_RECT_F,
};
use windows::Win32::Graphics::Direct2D::{
D2D1CreateFactory, ID2D1Factory, D2D1_DRAW_TEXT_OPTIONS_NONE, D2D1_FACTORY_TYPE_SINGLE_THREADED,
D2D1_FEATURE_LEVEL_DEFAULT, D2D1_RENDER_TARGET_PROPERTIES, D2D1_RENDER_TARGET_TYPE_DEFAULT,
D2D1_RENDER_TARGET_USAGE_NONE, D2D1_ROUNDED_RECT,
};
use windows::Win32::Graphics::Direct3D11::{
ID3D11Device, ID3D11ShaderResourceView, ID3D11Texture2D, D3D11_BIND_RENDER_TARGET,
D3D11_BIND_SHADER_RESOURCE, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT,
};
use windows::Win32::Graphics::DirectWrite::{
DWriteCreateFactory, IDWriteFactory, IDWriteFactory5, IDWriteFontCollection1,
DWRITE_FACTORY_TYPE_SHARED, DWRITE_FONT_STRETCH_NORMAL, DWRITE_FONT_STYLE_ITALIC,
DWRITE_FONT_STYLE_NORMAL,
DWRITE_FONT_WEIGHT_BOLD, DWRITE_FONT_WEIGHT_NORMAL, DWRITE_PARAGRAPH_ALIGNMENT_CENTER,
DWRITE_PARAGRAPH_ALIGNMENT_FAR, DWRITE_PARAGRAPH_ALIGNMENT_NEAR, DWRITE_TEXT_ALIGNMENT_CENTER,
DWRITE_TEXT_ALIGNMENT_LEADING, DWRITE_TEXT_ALIGNMENT_TRAILING, DWRITE_TEXT_METRICS,
DWRITE_TEXT_RANGE,
};
use windows::Win32::Graphics::Dxgi::Common::DXGI_FORMAT_B8G8R8A8_UNORM;
use windows::Win32::Graphics::Dxgi::Common::DXGI_SAMPLE_DESC;
use windows::Win32::Graphics::Dxgi::IDXGISurface;
/// Tout ce dont le rendu d'un texte dépend. Sert aussi de clé de cache : deux specs égales
/// donnent la même texture, donc `cache_key` couvre exactement ces champs.
#[derive(Clone, PartialEq)]
pub struct TextSpec {
pub content: String,
/// RGBA 0..1 (déjà parsé depuis la chaîne CSS côté appelant).
pub color: [f32; 4],
/// RGBA 0..1 ; alpha 0 = pas de fond (le CSS `transparent`).
pub background: [f32; 4],
pub font_size_px: f32,
pub font_family: String,
pub bold: bool,
pub italic: bool,
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],
}
impl TextSpec {
/// FNV-1a sur les champs. Utilisé pour décider s'il faut re-rastériser ; volontairement
/// insensible à tout ce qui n'affecte pas les pixels (position, opacité d'animation…).
pub fn cache_key(&self) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
let mut mix = |bytes: &[u8]| {
for b in bytes {
h ^= *b as u64;
h = h.wrapping_mul(0x100_0000_01b3);
}
};
mix(self.content.as_bytes());
mix(self.font_family.as_bytes());
mix(&self.font_size_px.to_bits().to_le_bytes());
for c in self.color.iter().chain(self.background.iter()) {
mix(&c.to_bits().to_le_bytes());
}
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
}
}
/// Chaîne UTF-16 terminée par un zéro, pour les API Win32 qui prennent un `PCWSTR`.
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
/// La plaque de fond, en `[left, top, right, bottom]` px dans la boîte, à partir des
/// métriques DirectWrite du bloc mis en page.
///
/// Fonction pure — et volontairement extraite du chemin de dessin : le rasteriseur
/// Windows exige un device D3D, donc tout ce qui reste inline dans `rasterize` n'est
/// couvert par aucun test. macOS a `block_layout` pour la même raison ; ceci met les
/// deux backends au même niveau, sur le calcul qui décide si la plaque se fait rogner.
///
/// Les deux annulations qui portent tout :
/// * horizontalement, le texte est dessiné à `pad_x` et commence donc à `pad_x + m.left` :
/// la plaque part de `m.left`, l'inset de la boîte de mise en page et la marge de
/// plaque se compensent exactement, quel que soit l'alignement ;
/// * verticalement, la plaque n'est dessinée QUE si le fond est opaque, et dans ce cas le
/// texte est dessiné à `pad_y` dans une boîte de mise en page rentrée de `2*pad_y`
/// (`anchor_pad` vaut alors `pad_y`). Son haut réel vaut donc `pad_y + m.top` et la
/// plaque va de `m.top` à `m.top + m.height + 2*pad_y`. Ancré en bas
/// (`DWRITE_PARAGRAPH_ALIGNMENT_FAR`), ce second terme tombe pile sur `box_h` : la
/// marge basse tient tout juste au lieu d'être rognée par le `.min()`, et la plaque
/// respire autant en dessous qu'au-dessus du texte.
///
/// Le bornage à la boîte est ce qui empêche la plaque d'être coupée net par le bord de
/// la texture, où elle perdrait ses coins arrondis.
fn plate_rect(metrics: [f32; 4], box_px: [f32; 2], pad_x: f32, pad_y: f32) -> [f32; 4] {
let [m_left, m_top, m_width, m_height] = metrics;
let [box_w, box_h] = box_px;
[
m_left.max(0.0),
m_top.max(0.0),
(m_left + m_width + pad_x * 2.0).min(box_w),
(m_top + m_height + pad_y * 2.0).min(box_h),
]
}
/// Une collection DirectWrite faite des seuls `files` (`crate::text_fonts`), sans rien installer
/// sur la machine. `None` quand il n'y a rien à charger.
///
/// PRIVÉE, et c'est voulu : une famille absente n'y tombe pas sur une police installée du même
/// nom, donc le rendu ne dépend plus de la machine. Les caractères qu'aucune police embarquée
/// ne couvre (CJK, arabe…) passent toujours par le repli du système, que la mise en page
/// applique quelle que soit la collection de base.
unsafe fn private_font_collection(
dwrite: &IDWriteFactory,
files: &[PathBuf],
) -> windows::core::Result<Option<IDWriteFontCollection1>> {
if files.is_empty() {
return Ok(None);
}
// `IDWriteFactory5` : Windows 10 1703 et après. Plus ancien, le `cast` échoue et
// l'appelant retombe sur les polices du système.
let factory: IDWriteFactory5 = dwrite.cast()?;
let builder = factory.CreateFontSetBuilder()?;
for path in files {
let wide_path: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
let file =
factory.CreateFontFileReference(windows::core::PCWSTR(wide_path.as_ptr()), None)?;
builder.AddFontFile(&file)?;
}
Ok(Some(factory.CreateFontCollectionFromFontSet(&builder.CreateFontSet()?)?))
}
pub struct TextRasterizer {
d2d: ID2D1Factory,
dwrite: IDWriteFactory,
/// Les polices embarquées, cf. `private_font_collection`. `None` : collection du système.
fonts: Option<IDWriteFontCollection1>,
}
impl TextRasterizer {
pub fn new() -> Result<TextRasterizer> {
unsafe {
// SINGLE_THREADED : tout le rendu du compositeur vit sur un seul thread, et le mode
// multithread ajoute un verrou par appel pour rien.
let d2d: ID2D1Factory =
D2D1CreateFactory(D2D1_FACTORY_TYPE_SINGLE_THREADED, None)?;
let dwrite: IDWriteFactory = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED)?;
// Un échec ne coûte que les polices embarquées, pas le texte : on le dit et on
// continue sur celles du système.
let fonts = private_font_collection(&dwrite, &crate::text_fonts::embedded_font_files())
.unwrap_or_else(|e| {
eprintln!("[text] polices embarquées non chargées ({e}) : polices du système");
None
});
Ok(TextRasterizer { d2d, dwrite, fonts })
}
}
/// Rastérise `spec` dans une texture neuve et rend sa SRV. Le fond éventuel est dessiné
/// derrière le texte, ajusté aux métriques de la mise en page — comme le CSS, où
/// `backgroundColor` est porté par le `<span>` et épouse donc le texte, pas la boîte —
/// avec la marge et les coins arrondis de `crate::text_plate`, partagés avec CoreText.
pub unsafe fn rasterize(
&self,
dev: &ID3D11Device,
spec: &TextSpec,
) -> Result<ID3D11ShaderResourceView> {
let (w, h) = (spec.box_px[0].max(1), spec.box_px[1].max(1));
if spec.content.is_empty() {
bail!("texte vide");
}
// B8G8R8A8 : le format qu'exige une cible de rendu D2D. DXGI expose déjà les composantes
// dans l'ordre RGBA au shader, donc rien à ré-échanger côté HLSL.
let desc = D3D11_TEXTURE2D_DESC {
Width: w,
Height: h,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 },
Usage: D3D11_USAGE_DEFAULT,
BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
CPUAccessFlags: 0,
MiscFlags: 0,
};
let mut tex: Option<ID3D11Texture2D> = None;
dev.CreateTexture2D(&desc, None, Some(&mut tex))?;
let tex = tex.unwrap();
let surface: IDXGISurface = tex.cast()?;
let props = D2D1_RENDER_TARGET_PROPERTIES {
r#type: D2D1_RENDER_TARGET_TYPE_DEFAULT,
pixelFormat: D2D1_PIXEL_FORMAT {
format: DXGI_FORMAT_B8G8R8A8_UNORM,
// PREMULTIPLIED : ce que D2D produit sur une surface DXGI. Le shader ne doit donc
// PAS re-multiplier par l'alpha (cf. mode 11 dans shaders.hlsl).
alphaMode: D2D1_ALPHA_MODE_PREMULTIPLIED,
},
// 96 dpi = 1 unité D2D pour 1 pixel : la mise en page se fait donc directement en
// pixels de sortie, ce qui rend `font_size_px` littéral.
dpiX: 96.0,
dpiY: 96.0,
usage: D2D1_RENDER_TARGET_USAGE_NONE,
minLevel: D2D1_FEATURE_LEVEL_DEFAULT,
};
let rt = self.d2d.CreateDxgiSurfaceRenderTarget(&surface, &props)?;
let family = wide(&spec.font_family);
let locale = wide("");
let format = self.dwrite.CreateTextFormat(
windows::core::PCWSTR(family.as_ptr()),
self.fonts.as_deref(),
if spec.bold { DWRITE_FONT_WEIGHT_BOLD } else { DWRITE_FONT_WEIGHT_NORMAL },
if spec.italic { DWRITE_FONT_STYLE_ITALIC } else { DWRITE_FONT_STYLE_NORMAL },
DWRITE_FONT_STRETCH_NORMAL,
spec.font_size_px.max(1.0),
windows::core::PCWSTR(locale.as_ptr()),
)?;
format.SetTextAlignment(match spec.align.as_str() {
"left" => DWRITE_TEXT_ALIGNMENT_LEADING,
"right" => DWRITE_TEXT_ALIGNMENT_TRAILING,
_ => DWRITE_TEXT_ALIGNMENT_CENTER,
})?;
// ANCRAGE vertical. `center` reproduit `alignItems: center` de l'overlay web et
// reste le comportement des annotations ; les sous-titres épinglent une arête,
// parce qu'un bloc centré voit ses DEUX arêtes bouger quand il gagne une ligne.
format.SetParagraphAlignment(match spec.valign.as_str() {
"top" | "start" => DWRITE_PARAGRAPH_ALIGNMENT_NEAR,
"bottom" | "end" => DWRITE_PARAGRAPH_ALIGNMENT_FAR,
_ => DWRITE_PARAGRAPH_ALIGNMENT_CENTER,
})?;
let text: Vec<u16> = spec.content.encode_utf16().collect();
// La boîte de mise en page est rentrée de la marge de plaque (cf. `text_plate`), et
// le texte se dessine à `pad_x` : sans cet inset, un texte aligné à gauche ou à
// droite colle au bord de la boîte et sa plaque se fait rogner du côté où elle
// devrait respirer.
let font_px = spec.font_size_px.max(1.0);
let (pad_x, pad_y) = crate::text_plate::padding(font_px);
let layout_w = crate::text_plate::layout_width(w as f32, font_px);
// La boîte de mise en page est aussi rentrée VERTICALEMENT de la marge de plaque,
// et le texte se dessine à `anchor_pad`. Sans ça, un ancrage bas colle les glyphes
// au bord de la boîte : la plaque pose alors toute sa marge du côté opposé et zéro
// du côté ancré, et le `.min(h)` plus bas rogne net sa marge basse. Ce qui doit
// toucher le bord de la boîte est la PLAQUE, pas les glyphes.
//
// Conditionné à la présence d'une plaque, comme sur les deux autres backends :
// sans fond il n'y a pas de marge à réserver, et réserver quand même décalerait
// le texte de `pad_y` par rapport à Linux et macOS. Le centrage est rigoureusement
// inchangé dans les deux cas (l'inset et le décalage s'annulent), donc les
// annotations ne bougent pas d'un pixel.
let anchor_pad = if spec.background[3] > 0.0 { pad_y } else { 0.0 };
let layout_h = ((h as f32) - anchor_pad * 2.0).max(1.0);
let layout = self
.dwrite
.CreateTextLayout(&text, &format, layout_w, layout_h)?;
if spec.underline {
layout.SetUnderline(
true,
DWRITE_TEXT_RANGE { startPosition: 0, length: text.len() as u32 },
)?;
}
let color = D2D1_COLOR_F {
r: spec.color[0],
g: spec.color[1],
b: spec.color[2],
a: spec.color[3],
};
let brush = rt.CreateSolidColorBrush(&color, None)?;
rt.BeginDraw();
rt.Clear(Some(&D2D1_COLOR_F { r: 0.0, g: 0.0, b: 0.0, a: 0.0 }));
if spec.background[3] > 0.0 {
let mut m = DWRITE_TEXT_METRICS::default();
layout.GetMetrics(&mut m)?;
let bg = D2D1_COLOR_F {
r: spec.background[0],
g: spec.background[1],
b: spec.background[2],
a: spec.background[3],
};
let bg_brush = rt.CreateSolidColorBrush(&bg, None)?;
let [pl, pt, pr, pb] =
plate_rect([m.left, m.top, m.width, m.height], [w as f32, h as f32], pad_x, pad_y);
let rect = D2D_RECT_F { left: pl, top: pt, right: pr, bottom: pb };
let radius = crate::text_plate::radius(
font_px,
(rect.right - rect.left).max(0.0),
(rect.bottom - rect.top).max(0.0),
);
rt.FillRoundedRectangle(
&D2D1_ROUNDED_RECT {
rect,
radiusX: radius,
radiusY: radius,
},
&bg_brush,
);
}
rt.DrawTextLayout(
D2D_POINT_2F { x: pad_x, y: anchor_pad },
&layout,
&brush,
D2D1_DRAW_TEXT_OPTIONS_NONE,
);
// `EndDraw` rapporte une perte de device par son HRESULT plutôt qu'en échouant tout de
// suite : on le propage pour que l'appelant sache que la texture n'est pas exploitable.
rt.EndDraw(None, None)?;
let mut srv: Option<ID3D11ShaderResourceView> = None;
dev.CreateShaderResourceView(&tex, None, Some(&mut srv))?;
Ok(srv.unwrap())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn spec(content: &str) -> TextSpec {
TextSpec {
content: content.into(),
color: [1.0, 1.0, 1.0, 1.0],
background: [0.0, 0.0, 0.0, 0.0],
font_size_px: 32.0,
font_family: "Inter".into(),
bold: true,
italic: false,
underline: false,
align: "center".into(),
valign: "center".into(),
box_px: [400, 120],
}
}
/// Métriques DirectWrite telles que `SetParagraphAlignment` les produit, pour un
/// bloc de `text_h` px dans une boîte de `box_h` : la mise en page se fait dans
/// `box_h - 2*pad_y` (cf. `rasterize`), et l'alignement décide de `m.top` dedans.
fn metrics_for(valign: &str, box_h: f32, text_h: f32, pad_y: f32) -> [f32; 4] {
let layout_h = (box_h - pad_y * 2.0).max(1.0);
let slack = (layout_h - text_h).max(0.0);
let top = match valign {
"top" => 0.0,
"bottom" => slack,
_ => slack * 0.5,
};
[0.0, top, 200.0, text_h]
}
#[test]
fn the_plate_survives_the_bottom_anchor_instead_of_being_clipped() {
// LE risque de la bascule d'ancrage sous Windows. Avec l'ancienne mise en page
// (boîte pleine hauteur, dessin à y=0), `FAR` collait les glyphes au bord et le
// `.min(box_h)` rognait net la marge basse de la plaque. Ici elle doit tomber
// pile sur le bord, marge comprise.
let (box_w, box_h, pad_y) = (400.0f32, 120.0f32, 4.8f32);
let m = metrics_for("bottom", box_h, 56.0, pad_y);
let [_, top, _, bottom] = plate_rect(m, [box_w, box_h], 9.6, pad_y);
assert!(
(bottom - box_h).abs() < 0.01,
"la plaque ancrée en bas devrait finir sur le bord de la boîte, pas à {bottom}"
);
assert!(top >= 0.0, "plaque hors boîte par le haut : {top}");
// Et elle fait bien la hauteur du bloc plus ses deux marges — donc rien n'a été rogné.
assert!(
((bottom - top) - (56.0 + pad_y * 2.0)).abs() < 0.01,
"la marge de la plaque a été rognée : {}px pour un bloc de 56 + 2*{pad_y}",
bottom - top
);
}
#[test]
fn the_centred_plate_is_exactly_where_it_was_before_the_anchor_landed() {
// La bascule d'ancrage a rentré la boîte de mise en page de 2*pad_y ET décalé le
// dessin de pad_y. Les deux DOIVENT s'annuler pour le centrage, sinon toutes les
// annotations existantes bougent. Référence : l'ancien calcul, boîte pleine
// hauteur, `top = m.top - pad_y`, `bottom = m.top + m.height + pad_y`.
let (box_w, box_h, pad_y, text_h) = (400.0f32, 120.0f32, 4.8f32, 56.0f32);
let legacy_top = (box_h - text_h) * 0.5 - pad_y;
let legacy_bottom = (box_h - text_h) * 0.5 + text_h + pad_y;
let m = metrics_for("center", box_h, text_h, pad_y);
let [_, top, _, bottom] = plate_rect(m, [box_w, box_h], 9.6, pad_y);
assert!(
(top - legacy_top).abs() < 0.01 && (bottom - legacy_bottom).abs() < 0.01,
"le centrage a bougé : ({top}, {bottom}) au lieu de ({legacy_top}, {legacy_bottom})"
);
}
#[test]
fn the_plate_never_leaves_the_box() {
// Un bloc plus grand que sa boîte : la plaque se contente de la boîte plutôt que
// d'être coupée net par le bord de la texture (elle y perdrait ses coins arrondis).
let (box_w, box_h) = (200.0f32, 60.0f32);
for valign in ["top", "center", "bottom"] {
let m = metrics_for(valign, box_h, 400.0, 4.8);
let [l, t, r, b] = plate_rect(m, [box_w, box_h], 9.6, 4.8);
assert!(l >= 0.0 && t >= 0.0, "{valign} : coin haut-gauche hors boîte ({l}, {t})");
assert!(r <= box_w + 0.01 && b <= box_h + 0.01, "{valign} : plaque hors boîte");
}
}
/// Bloc mis en page de « Hamburgefonstiv » à 100 px, `(largeur, hauteur de ligne)`. Pas de
/// device D3D : la mise en page DirectWrite suffit pour savoir quelle police a été prise.
unsafe fn laid_out_block(
dwrite: &IDWriteFactory,
fonts: &IDWriteFontCollection1,
family: &str,
) -> (f32, f32) {
let (family, locale) = (wide(family), wide("en-us"));
let format = dwrite
.CreateTextFormat(
windows::core::PCWSTR(family.as_ptr()),
Some(&**fonts),
DWRITE_FONT_WEIGHT_NORMAL,
DWRITE_FONT_STYLE_NORMAL,
DWRITE_FONT_STRETCH_NORMAL,
100.0,
windows::core::PCWSTR(locale.as_ptr()),
)
.unwrap();
let text: Vec<u16> = "Hamburgefonstiv".encode_utf16().collect();
let layout = dwrite.CreateTextLayout(&text, &format, 10_000.0, 1_000.0).unwrap();
let mut m = DWRITE_TEXT_METRICS::default();
layout.GetMetrics(&mut m).unwrap();
(m.width, m.height)
}
/// L'index de `family` dans `fonts`, ou `None` si elle n'y est pas.
unsafe fn family_index(fonts: &IDWriteFontCollection1, family: &str) -> Option<u32> {
let name = wide(family);
let (mut index, mut exists) = (0u32, windows::Win32::Foundation::BOOL(0));
fonts
.FindFamilyName(windows::core::PCWSTR(name.as_ptr()), &mut index, &mut exists)
.unwrap();
exists.as_bool().then_some(index)
}
#[test]
fn every_shipped_family_draws_from_its_own_files_never_from_the_system() {
use windows::Win32::Graphics::DirectWrite::DWRITE_FONT_SIMULATIONS_NONE;
unsafe {
let dwrite: IDWriteFactory = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED).unwrap();
let files = crate::text_fonts::font_files_in(&crate::text_fonts::repo_fonts_dir());
let fonts = private_font_collection(&dwrite, &files).unwrap().expect("collection");
// Privée : une police installée n'y est pas, donc aucun nom ne s'y résout en douce
// vers ce que la machine se trouve avoir.
assert!(family_index(&fonts, "Arial").is_none(), "la collection voit le système");
let mut blocks = Vec::new();
for family in crate::text_fonts::SHIPPED_FAMILIES {
let index = family_index(&fonts, family)
.unwrap_or_else(|| panic!("{family} absente de la collection embarquée"));
// Le réglage Gras prend un VRAI fichier gras, pas une simulation : DirectWrite
// épaissit, CoreText et cosmic-text non, et le rendu divergerait.
let bold = fonts
.GetFontFamily(index)
.unwrap()
.GetFirstMatchingFont(
DWRITE_FONT_WEIGHT_BOLD,
DWRITE_FONT_STRETCH_NORMAL,
DWRITE_FONT_STYLE_NORMAL,
)
.unwrap();
assert_eq!(bold.GetWeight(), DWRITE_FONT_WEIGHT_BOLD, "{family} : pas de gras");
assert_eq!(bold.GetSimulations(), DWRITE_FONT_SIMULATIONS_NONE, "{family}");
let (w, h) = laid_out_block(&dwrite, &fonts, family);
blocks.push((family, w, h));
}
crate::text_fonts::assert_distinct_blocks(&blocks);
}
}
#[test]
fn identical_specs_share_a_cache_key() {
assert_eq!(spec("Bonjour").cache_key(), spec("Bonjour").cache_key());
}
#[test]
fn the_key_changes_with_anything_that_changes_the_pixels() {
let base = spec("Bonjour").cache_key();
let mut other = spec("Bonsoir");
assert_ne!(other.cache_key(), base, "contenu");
other = spec("Bonjour");
other.font_size_px = 33.0;
assert_ne!(other.cache_key(), base, "taille");
other = spec("Bonjour");
other.italic = true;
assert_ne!(other.cache_key(), base, "style");
other = spec("Bonjour");
other.color = [1.0, 0.0, 0.0, 1.0];
assert_ne!(other.cache_key(), base, "couleur");
other = spec("Bonjour");
other.align = "left".into();
assert_ne!(other.cache_key(), base, "alignement");
other = spec("Bonjour");
// Sans ça, deux sous-titres ne différant que par l'ancrage se partageraient
// une texture et rendraient les pixels l'un de l'autre.
other.valign = "bottom".into();
assert_ne!(other.cache_key(), base, "ancrage vertical");
other = spec("Bonjour");
// La taille de boîte compte : elle décide des retours à la ligne, donc des pixels.
other.box_px = [401, 120];
assert_ne!(other.cache_key(), base, "boîte");
}
#[test]
fn the_key_is_stable_across_unicode_content() {
// Le façonnage est délégué à DirectWrite ; la clé ne doit pas pour autant se briser sur
// du non-ASCII (l'app expédie en 13 langues).
for text in ["مرحبا", "こんにちは", "Grüße", "Здравствуйте"] {
assert_eq!(spec(text).cache_key(), spec(text).cache_key());
}
assert_ne!(spec("مرحبا").cache_key(), spec("こんにちは").cache_key());
}
}