use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum JustifyMode {
#[default]
Left,
Right,
Center,
Full,
Distributed,
}
impl JustifyMode {
#[must_use]
pub const fn requires_justification(&self) -> bool {
matches!(self, Self::Full | Self::Distributed)
}
#[must_use]
pub const fn justify_last_line(&self) -> bool {
matches!(self, Self::Distributed)
}
}
impl fmt::Display for JustifyMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Left => write!(f, "left"),
Self::Right => write!(f, "right"),
Self::Center => write!(f, "center"),
Self::Full => write!(f, "full"),
Self::Distributed => write!(f, "distributed"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum SpaceCategory {
#[default]
InterWord,
InterSentence,
InterCharacter,
}
impl fmt::Display for SpaceCategory {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InterWord => write!(f, "inter-word"),
Self::InterSentence => write!(f, "inter-sentence"),
Self::InterCharacter => write!(f, "inter-character"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct GlueSpec {
pub natural_subcell: u32,
pub stretch_subcell: u32,
pub shrink_subcell: u32,
}
pub const SUBCELL_SCALE: u32 = 256;
impl GlueSpec {
pub const WORD_SPACE: Self = Self {
natural_subcell: SUBCELL_SCALE, stretch_subcell: SUBCELL_SCALE / 2, shrink_subcell: SUBCELL_SCALE / 3, };
pub const SENTENCE_SPACE: Self = Self {
natural_subcell: SUBCELL_SCALE * 3 / 2, stretch_subcell: SUBCELL_SCALE, shrink_subcell: SUBCELL_SCALE / 3, };
pub const FRENCH_SPACE: Self = Self::WORD_SPACE;
pub const INTER_CHAR: Self = Self {
natural_subcell: 0,
stretch_subcell: SUBCELL_SCALE / 16, shrink_subcell: SUBCELL_SCALE / 32, };
#[must_use]
pub const fn rigid(width_subcell: u32) -> Self {
Self {
natural_subcell: width_subcell,
stretch_subcell: 0,
shrink_subcell: 0,
}
}
#[must_use]
pub fn adjusted_width(&self, ratio_fixed: i32) -> u32 {
if ratio_fixed == 0 {
return self.natural_subcell;
}
if ratio_fixed > 0 {
let delta = (self.stretch_subcell as u64 * ratio_fixed as u64) / SUBCELL_SCALE as u64;
self.natural_subcell
.saturating_add(delta.min(self.stretch_subcell as u64) as u32)
} else {
let abs_ratio = ratio_fixed.unsigned_abs();
let delta = (self.shrink_subcell as u64 * abs_ratio as u64) / SUBCELL_SCALE as u64;
self.natural_subcell
.saturating_sub(delta.min(self.shrink_subcell as u64) as u32)
}
}
#[must_use]
pub const fn elasticity(&self) -> u32 {
self.stretch_subcell.saturating_add(self.shrink_subcell)
}
#[must_use]
pub const fn is_rigid(&self) -> bool {
self.stretch_subcell == 0 && self.shrink_subcell == 0
}
}
impl Default for GlueSpec {
fn default() -> Self {
Self::WORD_SPACE
}
}
impl fmt::Display for GlueSpec {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let nat = self.natural_subcell as f64 / SUBCELL_SCALE as f64;
let st = self.stretch_subcell as f64 / SUBCELL_SCALE as f64;
let sh = self.shrink_subcell as f64 / SUBCELL_SCALE as f64;
write!(f, "{nat:.2} +{st:.2} -{sh:.2}")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SpacePenalty {
pub excessive_stretch: u64,
pub excessive_shrink: u64,
pub tracking_penalty: u64,
}
impl SpacePenalty {
pub const DEFAULT: Self = Self {
excessive_stretch: 50,
excessive_shrink: 80,
tracking_penalty: 200,
};
pub const PERMISSIVE: Self = Self {
excessive_stretch: 10,
excessive_shrink: 20,
tracking_penalty: 50,
};
pub const STRICT: Self = Self {
excessive_stretch: 200,
excessive_shrink: 300,
tracking_penalty: 1000,
};
#[must_use]
pub fn evaluate(&self, ratio_fixed: i32, category: SpaceCategory) -> u64 {
let mut penalty = 0u64;
const THRESHOLD: i32 = 192;
if ratio_fixed > THRESHOLD {
penalty = penalty.saturating_add(self.excessive_stretch);
} else if ratio_fixed < -THRESHOLD {
penalty = penalty.saturating_add(self.excessive_shrink);
}
if category == SpaceCategory::InterCharacter && ratio_fixed != 0 {
penalty = penalty.saturating_add(self.tracking_penalty);
}
penalty
}
}
impl Default for SpacePenalty {
fn default() -> Self {
Self::DEFAULT
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct JustificationControl {
pub mode: JustifyMode,
pub word_space: GlueSpec,
pub sentence_space: GlueSpec,
pub char_space: GlueSpec,
pub penalties: SpacePenalty,
pub french_spacing: bool,
pub max_consecutive_hyphens: u8,
pub emergency_stretch_factor: u32,
}
impl JustificationControl {
pub const TERMINAL: Self = Self {
mode: JustifyMode::Left,
word_space: GlueSpec::rigid(SUBCELL_SCALE),
sentence_space: GlueSpec::rigid(SUBCELL_SCALE),
char_space: GlueSpec::rigid(0),
penalties: SpacePenalty::DEFAULT,
french_spacing: true,
max_consecutive_hyphens: 0, emergency_stretch_factor: SUBCELL_SCALE,
};
pub const READABLE: Self = Self {
mode: JustifyMode::Full,
word_space: GlueSpec::WORD_SPACE,
sentence_space: GlueSpec::FRENCH_SPACE, char_space: GlueSpec::rigid(0), penalties: SpacePenalty::DEFAULT,
french_spacing: true,
max_consecutive_hyphens: 3,
emergency_stretch_factor: SUBCELL_SCALE * 3 / 2, };
pub const TYPOGRAPHIC: Self = Self {
mode: JustifyMode::Full,
word_space: GlueSpec::WORD_SPACE,
sentence_space: GlueSpec::SENTENCE_SPACE,
char_space: GlueSpec::INTER_CHAR,
penalties: SpacePenalty::STRICT,
french_spacing: false,
max_consecutive_hyphens: 2,
emergency_stretch_factor: SUBCELL_SCALE * 2, };
#[must_use]
pub const fn glue_for(&self, category: SpaceCategory) -> GlueSpec {
match category {
SpaceCategory::InterWord => self.word_space,
SpaceCategory::InterSentence => {
if self.french_spacing {
self.word_space
} else {
self.sentence_space
}
}
SpaceCategory::InterCharacter => self.char_space,
}
}
#[must_use]
pub fn total_natural(&self, spaces: &[SpaceCategory]) -> u32 {
spaces
.iter()
.map(|cat| self.glue_for(*cat).natural_subcell)
.fold(0u32, u32::saturating_add)
}
#[must_use]
pub fn total_stretch(&self, spaces: &[SpaceCategory]) -> u32 {
spaces
.iter()
.map(|cat| self.glue_for(*cat).stretch_subcell)
.fold(0u32, u32::saturating_add)
}
#[must_use]
pub fn total_shrink(&self, spaces: &[SpaceCategory]) -> u32 {
spaces
.iter()
.map(|cat| self.glue_for(*cat).shrink_subcell)
.fold(0u32, u32::saturating_add)
}
#[must_use]
pub fn adjustment_ratio(
&self,
slack_subcell: i32,
total_stretch: u32,
total_shrink: u32,
) -> Option<i32> {
if slack_subcell == 0 {
return Some(0);
}
if slack_subcell > 0 {
if total_stretch == 0 {
return None; }
let ratio = (slack_subcell as i64 * SUBCELL_SCALE as i64) / total_stretch as i64;
Some(ratio.min(i32::MAX as i64) as i32)
} else {
if total_shrink == 0 {
return None; }
let ratio = (slack_subcell as i64 * SUBCELL_SCALE as i64) / total_shrink as i64;
if ratio < -(SUBCELL_SCALE as i64) {
None } else {
Some(ratio as i32)
}
}
}
#[must_use]
pub fn badness(ratio_fixed: i32) -> u64 {
const BADNESS_SCALE: u64 = 10_000;
if ratio_fixed == 0 {
return 0;
}
let abs_r = ratio_fixed.unsigned_abs() as u64;
let cube = abs_r.saturating_mul(abs_r).saturating_mul(abs_r);
cube.saturating_mul(BADNESS_SCALE) / (SUBCELL_SCALE as u64).pow(3)
}
#[must_use]
pub fn line_demerits(
&self,
ratio_fixed: i32,
spaces: &[SpaceCategory],
break_penalty: i64,
) -> u64 {
let badness = Self::badness(ratio_fixed);
if badness == u64::MAX {
return u64::MAX;
}
let base = badness.saturating_add(10); let demerits = base.saturating_mul(base);
let bp = break_penalty.unsigned_abs();
let demerits = demerits.saturating_add(bp.saturating_mul(bp));
let space_penalty: u64 = spaces
.iter()
.map(|cat| self.penalties.evaluate(ratio_fixed, *cat))
.sum();
demerits.saturating_add(space_penalty)
}
#[must_use]
pub fn validate(&self) -> Vec<&'static str> {
let mut warnings = Vec::new();
if self.mode.requires_justification() && self.word_space.is_rigid() {
warnings.push("justified mode with rigid word space cannot modulate spacing");
}
if self.word_space.shrink_subcell > self.word_space.natural_subcell {
warnings.push("word space shrink exceeds natural width (would go negative)");
}
if self.sentence_space.shrink_subcell > self.sentence_space.natural_subcell {
warnings.push("sentence space shrink exceeds natural width (would go negative)");
}
if self.emergency_stretch_factor == 0 {
warnings.push("emergency stretch factor is zero (no emergency fallback)");
}
warnings
}
}
impl Default for JustificationControl {
fn default() -> Self {
Self::TERMINAL
}
}
impl fmt::Display for JustificationControl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"mode={} word=[{}] french={}",
self.mode, self.word_space, self.french_spacing
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn left_does_not_require_justification() {
assert!(!JustifyMode::Left.requires_justification());
}
#[test]
fn full_requires_justification() {
assert!(JustifyMode::Full.requires_justification());
}
#[test]
fn distributed_requires_justification() {
assert!(JustifyMode::Distributed.requires_justification());
}
#[test]
fn full_does_not_justify_last_line() {
assert!(!JustifyMode::Full.justify_last_line());
}
#[test]
fn distributed_justifies_last_line() {
assert!(JustifyMode::Distributed.justify_last_line());
}
#[test]
fn default_mode_is_left() {
assert_eq!(JustifyMode::default(), JustifyMode::Left);
}
#[test]
fn mode_display() {
assert_eq!(format!("{}", JustifyMode::Full), "full");
assert_eq!(format!("{}", JustifyMode::Center), "center");
}
#[test]
fn default_category_is_inter_word() {
assert_eq!(SpaceCategory::default(), SpaceCategory::InterWord);
}
#[test]
fn category_display() {
assert_eq!(format!("{}", SpaceCategory::InterWord), "inter-word");
assert_eq!(
format!("{}", SpaceCategory::InterSentence),
"inter-sentence"
);
assert_eq!(
format!("{}", SpaceCategory::InterCharacter),
"inter-character"
);
}
#[test]
fn word_space_constants() {
let g = GlueSpec::WORD_SPACE;
assert_eq!(g.natural_subcell, 256);
assert_eq!(g.stretch_subcell, 128);
assert_eq!(g.shrink_subcell, 85); }
#[test]
fn sentence_space_wider() {
let sentence = GlueSpec::SENTENCE_SPACE;
let word = GlueSpec::WORD_SPACE;
assert!(sentence.natural_subcell > word.natural_subcell);
}
#[test]
fn rigid_has_no_elasticity() {
let g = GlueSpec::rigid(256);
assert!(g.is_rigid());
assert_eq!(g.elasticity(), 0);
}
#[test]
fn word_space_is_not_rigid() {
assert!(!GlueSpec::WORD_SPACE.is_rigid());
}
#[test]
fn adjusted_width_at_zero_is_natural() {
let g = GlueSpec::WORD_SPACE;
assert_eq!(g.adjusted_width(0), g.natural_subcell);
}
#[test]
fn adjusted_width_full_stretch() {
let g = GlueSpec::WORD_SPACE;
let w = g.adjusted_width(256);
assert_eq!(w, g.natural_subcell + g.stretch_subcell);
}
#[test]
fn adjusted_width_full_shrink() {
let g = GlueSpec::WORD_SPACE;
let w = g.adjusted_width(-256);
assert_eq!(w, g.natural_subcell - g.shrink_subcell);
}
#[test]
fn adjusted_width_partial_stretch() {
let g = GlueSpec::WORD_SPACE;
let w = g.adjusted_width(128);
assert_eq!(w, g.natural_subcell + 64);
}
#[test]
fn adjusted_width_clamps_stretch() {
let g = GlueSpec::WORD_SPACE;
let w = g.adjusted_width(1024);
assert_eq!(w, g.natural_subcell + g.stretch_subcell);
}
#[test]
fn adjusted_width_clamps_shrink() {
let g = GlueSpec::WORD_SPACE;
let w = g.adjusted_width(-1024);
assert_eq!(w, g.natural_subcell - g.shrink_subcell);
}
#[test]
fn rigid_adjusted_width_ignores_ratio() {
let g = GlueSpec::rigid(512);
assert_eq!(g.adjusted_width(256), 512);
assert_eq!(g.adjusted_width(-256), 512);
}
#[test]
fn elasticity_is_sum() {
let g = GlueSpec::WORD_SPACE;
assert_eq!(g.elasticity(), g.stretch_subcell + g.shrink_subcell);
}
#[test]
fn glue_display() {
let s = format!("{}", GlueSpec::WORD_SPACE);
assert!(s.contains('+'));
assert!(s.contains('-'));
}
#[test]
fn default_glue_is_word_space() {
assert_eq!(GlueSpec::default(), GlueSpec::WORD_SPACE);
}
#[test]
fn french_space_equals_word_space() {
assert_eq!(GlueSpec::FRENCH_SPACE, GlueSpec::WORD_SPACE);
}
#[test]
fn penalty_no_adjustment_is_zero() {
let p = SpacePenalty::DEFAULT;
assert_eq!(p.evaluate(0, SpaceCategory::InterWord), 0);
}
#[test]
fn penalty_moderate_stretch_is_zero() {
let p = SpacePenalty::DEFAULT;
assert_eq!(p.evaluate(128, SpaceCategory::InterWord), 0);
}
#[test]
fn penalty_excessive_stretch() {
let p = SpacePenalty::DEFAULT;
let d = p.evaluate(200, SpaceCategory::InterWord);
assert_eq!(d, p.excessive_stretch);
}
#[test]
fn penalty_excessive_shrink() {
let p = SpacePenalty::DEFAULT;
let d = p.evaluate(-200, SpaceCategory::InterWord);
assert_eq!(d, p.excessive_shrink);
}
#[test]
fn penalty_tracking_always_penalized() {
let p = SpacePenalty::DEFAULT;
let d = p.evaluate(1, SpaceCategory::InterCharacter);
assert_eq!(d, p.tracking_penalty);
}
#[test]
fn penalty_tracking_plus_excessive() {
let p = SpacePenalty::DEFAULT;
let d = p.evaluate(200, SpaceCategory::InterCharacter);
assert_eq!(d, p.excessive_stretch + p.tracking_penalty);
}
#[test]
fn penalty_zero_tracking_no_penalty() {
let p = SpacePenalty::DEFAULT;
assert_eq!(p.evaluate(0, SpaceCategory::InterCharacter), 0);
}
#[test]
fn terminal_is_left_rigid() {
let j = JustificationControl::TERMINAL;
assert_eq!(j.mode, JustifyMode::Left);
assert!(j.word_space.is_rigid());
}
#[test]
fn readable_is_full_elastic() {
let j = JustificationControl::READABLE;
assert_eq!(j.mode, JustifyMode::Full);
assert!(!j.word_space.is_rigid());
}
#[test]
fn typographic_has_tracking() {
let j = JustificationControl::TYPOGRAPHIC;
assert!(!j.char_space.is_rigid());
}
#[test]
fn french_spacing_overrides_sentence() {
let j = JustificationControl::READABLE;
assert!(j.french_spacing);
assert_eq!(
j.glue_for(SpaceCategory::InterSentence),
j.glue_for(SpaceCategory::InterWord)
);
}
#[test]
fn non_french_uses_sentence_space() {
let j = JustificationControl::TYPOGRAPHIC;
assert!(!j.french_spacing);
assert_ne!(
j.glue_for(SpaceCategory::InterSentence).natural_subcell,
j.glue_for(SpaceCategory::InterWord).natural_subcell
);
}
#[test]
fn total_natural_sums() {
let j = JustificationControl::READABLE;
let spaces = vec![SpaceCategory::InterWord; 5];
assert_eq!(j.total_natural(&spaces), 5 * j.word_space.natural_subcell);
}
#[test]
fn total_stretch_sums() {
let j = JustificationControl::READABLE;
let spaces = vec![SpaceCategory::InterWord; 3];
assert_eq!(j.total_stretch(&spaces), 3 * j.word_space.stretch_subcell);
}
#[test]
fn total_shrink_sums() {
let j = JustificationControl::READABLE;
let spaces = vec![SpaceCategory::InterWord; 4];
assert_eq!(j.total_shrink(&spaces), 4 * j.word_space.shrink_subcell);
}
#[test]
fn ratio_zero_slack() {
let j = JustificationControl::READABLE;
assert_eq!(j.adjustment_ratio(0, 100, 100), Some(0));
}
#[test]
fn ratio_positive_stretch() {
let j = JustificationControl::READABLE;
assert_eq!(j.adjustment_ratio(128, 256, 100), Some(128));
}
#[test]
fn ratio_negative_shrink() {
let j = JustificationControl::READABLE;
assert_eq!(j.adjustment_ratio(-64, 100, 128), Some(-128));
}
#[test]
fn ratio_no_stretch_returns_none() {
let j = JustificationControl::READABLE;
assert_eq!(j.adjustment_ratio(100, 0, 100), None);
}
#[test]
fn ratio_no_shrink_returns_none() {
let j = JustificationControl::READABLE;
assert_eq!(j.adjustment_ratio(-100, 100, 0), None);
}
#[test]
fn ratio_over_shrink_returns_none() {
let j = JustificationControl::READABLE;
assert_eq!(j.adjustment_ratio(-300, 100, 100), None);
}
#[test]
fn badness_zero_ratio() {
assert_eq!(JustificationControl::badness(0), 0);
}
#[test]
fn badness_ratio_256_is_scale() {
assert_eq!(JustificationControl::badness(256), 10_000);
}
#[test]
fn badness_negative_same_as_positive() {
assert_eq!(
JustificationControl::badness(128),
JustificationControl::badness(-128)
);
}
#[test]
fn badness_half_ratio() {
assert_eq!(JustificationControl::badness(128), 1250);
}
#[test]
fn badness_monotonically_increasing() {
let b0 = JustificationControl::badness(0);
let b1 = JustificationControl::badness(64);
let b2 = JustificationControl::badness(128);
let b3 = JustificationControl::badness(256);
assert!(b0 < b1);
assert!(b1 < b2);
assert!(b2 < b3);
}
#[test]
fn demerits_zero_ratio_minimal() {
let j = JustificationControl::READABLE;
let spaces = vec![SpaceCategory::InterWord; 3];
let d = j.line_demerits(0, &spaces, 0);
assert_eq!(d, 100);
}
#[test]
fn demerits_increase_with_ratio() {
let j = JustificationControl::READABLE;
let spaces = vec![SpaceCategory::InterWord; 3];
let d1 = j.line_demerits(64, &spaces, 0);
let d2 = j.line_demerits(128, &spaces, 0);
assert!(d2 > d1);
}
#[test]
fn demerits_include_break_penalty() {
let j = JustificationControl::READABLE;
let spaces = vec![SpaceCategory::InterWord; 3];
let d0 = j.line_demerits(0, &spaces, 0);
let d1 = j.line_demerits(0, &spaces, 50);
assert!(d1 > d0);
}
#[test]
fn terminal_validates_clean() {
assert!(JustificationControl::TERMINAL.validate().is_empty());
}
#[test]
fn readable_validates_clean() {
assert!(JustificationControl::READABLE.validate().is_empty());
}
#[test]
fn typographic_validates_clean() {
assert!(JustificationControl::TYPOGRAPHIC.validate().is_empty());
}
#[test]
fn full_mode_rigid_warns() {
let mut j = JustificationControl::TERMINAL;
j.mode = JustifyMode::Full;
let warnings = j.validate();
assert!(!warnings.is_empty());
}
#[test]
fn shrink_exceeds_natural_warns() {
let mut j = JustificationControl::READABLE;
j.word_space.shrink_subcell = j.word_space.natural_subcell + 1;
let warnings = j.validate();
assert!(
warnings
.iter()
.any(|w| w.contains("shrink exceeds natural"))
);
}
#[test]
fn zero_emergency_factor_warns() {
let mut j = JustificationControl::READABLE;
j.emergency_stretch_factor = 0;
let warnings = j.validate();
assert!(warnings.iter().any(|w| w.contains("emergency")));
}
#[test]
fn control_display() {
let s = format!("{}", JustificationControl::READABLE);
assert!(s.contains("full"));
assert!(s.contains("french=true"));
}
#[test]
fn default_control_is_terminal() {
assert_eq!(
JustificationControl::default(),
JustificationControl::TERMINAL
);
}
#[test]
fn same_inputs_same_badness() {
assert_eq!(
JustificationControl::badness(200),
JustificationControl::badness(200)
);
}
#[test]
fn same_inputs_same_demerits() {
let j = JustificationControl::TYPOGRAPHIC;
let spaces = vec![SpaceCategory::InterWord; 5];
let d1 = j.line_demerits(150, &spaces, 50);
let d2 = j.line_demerits(150, &spaces, 50);
assert_eq!(d1, d2);
}
#[test]
fn same_inputs_same_ratio() {
let j = JustificationControl::READABLE;
assert_eq!(
j.adjustment_ratio(100, 200, 100),
j.adjustment_ratio(100, 200, 100)
);
}
}