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
//! Subtitle position normalisation.
//!
//! Converts raw [`SubtitlePosition`] values (which may use arbitrary
//! coordinate systems, pixel positions, or percentage-based offsets) into a
//! canonical [`NormalizedPosition`] with percentage-based coordinates in the
//! range `[0.0, 100.0]` and one of three vertical placement modes:
//! **top**, **center**, or **bottom**.
use crate::style::Position;
// ── Public types ──────────────────────────────────────────────────────────────
/// Vertical placement mode for a normalised subtitle position.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VerticalMode {
/// Subtitle is placed near the top of the video frame (≤ 33 %).
Top,
/// Subtitle is placed near the vertical center (34 %–66 %).
Center,
/// Subtitle is placed near the bottom of the video frame (≥ 67 %).
Bottom,
}
/// A normalised subtitle position with percentage coordinates and a named
/// vertical placement mode.
#[derive(Debug, Clone, PartialEq)]
pub struct NormalizedPosition {
/// Horizontal position as a percentage of frame width, in `[0.0, 100.0]`.
pub x_pct: f32,
/// Vertical position as a percentage of frame height, in `[0.0, 100.0]`.
pub y_pct: f32,
/// Named vertical placement derived from `y_pct`.
pub vertical_mode: VerticalMode,
}
// ── SubtitlePositionNormalizer ────────────────────────────────────────────────
/// Normalises raw subtitle positions to canonical percentage-based coordinates.
///
/// # Example
///
/// ```rust
/// use oximedia_subtitle::position::{SubtitlePositionNormalizer, VerticalMode};
/// use oximedia_subtitle::style::Position;
///
/// let pos = Position::new(0.5, 0.9);
/// let norm = SubtitlePositionNormalizer::normalize(&pos);
/// assert_eq!(norm.vertical_mode, VerticalMode::Bottom);
/// assert!((norm.y_pct - 90.0).abs() < 0.1);
/// ```
pub struct SubtitlePositionNormalizer;
impl SubtitlePositionNormalizer {
/// Normalise a raw [`Position`] to a [`NormalizedPosition`].
///
/// The `Position::x` and `Position::y` fields are expected to be in the
/// range `[0.0, 100.0]` (percentage of frame). Values outside this range
/// are clamped. Values greater than `1.0` are treated as percentages;
/// values ≤ `1.0` (fractional) are multiplied by 100 to convert them.
#[must_use]
pub fn normalize(pos: &Position) -> NormalizedPosition {
let x_pct = normalise_coord(pos.x);
let y_pct = normalise_coord(pos.y);
let vertical_mode = if y_pct <= 33.0 {
VerticalMode::Top
} else if y_pct <= 66.0 {
VerticalMode::Center
} else {
VerticalMode::Bottom
};
NormalizedPosition {
x_pct,
y_pct,
vertical_mode,
}
}
/// Create a default bottom-center position (standard subtitle placement).
#[must_use]
pub fn default_bottom() -> NormalizedPosition {
NormalizedPosition {
x_pct: 50.0,
y_pct: 90.0,
vertical_mode: VerticalMode::Bottom,
}
}
/// Create a top-center position (e.g. for upper third titles).
#[must_use]
pub fn default_top() -> NormalizedPosition {
NormalizedPosition {
x_pct: 50.0,
y_pct: 10.0,
vertical_mode: VerticalMode::Top,
}
}
}
// ── Internal helpers ──────────────────────────────────────────────────────────
/// Normalise a single coordinate value.
///
/// - Values in `(1.0, 100.0]` are treated as percentages and clamped.
/// - Values in `[0.0, 1.0]` are treated as fractions and scaled to `[0, 100]`.
/// - Negative values are clamped to 0.
/// - Values > 100 are clamped to 100.
fn normalise_coord(v: f32) -> f32 {
if v <= 1.0 && v >= 0.0 {
// Fractional (0–1): scale to percentage.
v * 100.0
} else {
v.clamp(0.0, 100.0)
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn pos(x: f32, y: f32) -> Position {
Position::new(x, y)
}
#[test]
fn test_bottom_mode_fractional() {
// y=0.9 → 90% → Bottom
let n = SubtitlePositionNormalizer::normalize(&pos(0.5, 0.9));
assert_eq!(n.vertical_mode, VerticalMode::Bottom);
assert!((n.x_pct - 50.0).abs() < 0.5);
assert!((n.y_pct - 90.0).abs() < 0.5);
}
#[test]
fn test_top_mode() {
// y=0.1 → 10% → Top
let n = SubtitlePositionNormalizer::normalize(&pos(0.25, 0.1));
assert_eq!(n.vertical_mode, VerticalMode::Top);
}
#[test]
fn test_center_mode() {
// y=0.5 → 50% → Center
let n = SubtitlePositionNormalizer::normalize(&pos(0.5, 0.5));
assert_eq!(n.vertical_mode, VerticalMode::Center);
}
#[test]
fn test_fractional_input_scaled() {
let n = SubtitlePositionNormalizer::normalize(&pos(0.5, 0.9));
assert!((n.x_pct - 50.0).abs() < 0.5);
assert!((n.y_pct - 90.0).abs() < 0.5);
assert_eq!(n.vertical_mode, VerticalMode::Bottom);
}
#[test]
fn test_over_1_clamped_to_100() {
// Values > 1 but ≤ 100 are treated as percentages.
let n = SubtitlePositionNormalizer::normalize(&pos(50.0, 90.0));
assert!((n.x_pct - 50.0).abs() < 0.01);
assert!((n.y_pct - 90.0).abs() < 0.01);
}
#[test]
fn test_boundary_top_center() {
// y=0.33 → 33% → Top boundary
let n = SubtitlePositionNormalizer::normalize(&pos(0.0, 0.33));
assert_eq!(n.vertical_mode, VerticalMode::Top);
}
#[test]
fn test_boundary_center() {
// y=0.5 → 50% → Center
let n = SubtitlePositionNormalizer::normalize(&pos(0.0, 0.5));
assert_eq!(n.vertical_mode, VerticalMode::Center);
}
#[test]
fn test_boundary_bottom() {
// y=0.67 → 67% → Bottom
let n = SubtitlePositionNormalizer::normalize(&pos(0.0, 0.67));
assert_eq!(n.vertical_mode, VerticalMode::Bottom);
}
}