From 6a66b95cbc2c4d4b732630113eb9a37263658c58 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 13:49:38 -0400 Subject: [PATCH] Enforce Rust coding standards via Clippy (#372) * chore(lints): enforce workspace Clippy standards Deny cognitive_complexity (threshold 30, rust:S3776), too_many_arguments and wildcard_imports workspace-wide. Existing violations are fixed in follow-up commits. * refactor(keymap): isolate shortcut row rendering * test(ui): cover dock command contract cases * fix(keymap): remove unused shortcut layout state * refactor(media): simplify container serialization tests * docs: document workspace lint inheritance for new crates * refactor(text): split Knuth-Plass line costing and path extraction Brings knuth_plass under the workspace cognitive complexity limit. * test(font-store): split lookup test by concern --- CLAUDE.md | 3 + Cargo.toml | 3 + clippy.toml | 1 + crates/font-store/src/tests.rs | 8 + crates/keymap/src/ui/view.rs | 207 ++++++++++++------- crates/media/src/isobmff/codec.rs | 202 ++++++++++-------- crates/media/src/isobmff/demux.rs | 23 ++- crates/media/src/isobmff/mux.rs | 11 +- crates/media/src/matroska/demux.rs | 12 +- crates/media/src/matroska/meta.rs | 17 +- crates/media/src/matroska/mux.rs | 8 +- crates/media/src/matroska/synthetic_tests.rs | 108 +++++----- crates/media/tests/oracle_matroska.rs | 164 ++++++++------- crates/text/src/linebreak/knuth_plass.rs | 101 +++++---- crates/ui/src/docking/command_tests.rs | 62 ++++-- crates/ui/src/text.rs | 200 +++++++++--------- 16 files changed, 687 insertions(+), 443 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 11908a75..95c995b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1,4 @@ Read `AGENTS.md`. + +New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit +`[workspace.lints]`; production crate roots must begin with the required crate-level lint attribute. diff --git a/Cargo.toml b/Cargo.toml index a71f9fd6..daa783f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,9 @@ unsafe_code = "forbid" unexpected_cfgs = { level = "warn", check-cfg = ["cfg(craft_pdf_unicode_fixtures)"] } [workspace.lints.clippy] +cognitive_complexity = "deny" +too_many_arguments = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" diff --git a/clippy.toml b/clippy.toml index 287e14ed..8f54197d 100644 --- a/clippy.toml +++ b/clippy.toml @@ -2,6 +2,7 @@ allow-unwrap-in-tests = true allow-expect-in-tests = true allow-panic-in-tests = true allow-indexing-slicing-in-tests = true +cognitive-complexity-threshold = 30 disallowed-types = [ { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, ] diff --git a/crates/font-store/src/tests.rs b/crates/font-store/src/tests.rs index de1aece8..44b75b1c 100644 --- a/crates/font-store/src/tests.rs +++ b/crates/font-store/src/tests.rs @@ -192,7 +192,11 @@ fn lookups_match_exactly_and_refuse_near_matches() { assert_eq!(name("Test Var", "Light").unwrap(), "TestVar[wght].ttf"); assert!(matches!(name("Test Var", "Thin"), Err(Unavailable::NoStyle { .. }))); assert!(matches!(name("Test Var", "Bold Italic"), Err(Unavailable::NoStyle { .. }))); +} +#[test] +fn postscript_lookups_match_exactly() { + let index = index_of(&fixture().index).unwrap(); assert_eq!(index.find_postscript("TestSans-Bold").unwrap().family.family, "Test Sans"); assert_eq!(index.find_postscript("TestVar-SemiBold").unwrap().file.file_name(), "TestVar[wght].ttf"); assert!(matches!(index.find_postscript("testsans-bold"), Err(Unavailable::NoPostScript(_)))); @@ -200,7 +204,11 @@ fn lookups_match_exactly_and_refuse_near_matches() { let Err(Unavailable::Ambiguous { families, .. }) = index.find_postscript("Shared-Regular") else { panic!("not ambiguous") }; assert_eq!(families, ["Shared One", "Shared Two"]); assert!(index.family("Shared Two").unwrap().face_by_postscript("Shared-Regular").is_some()); +} +#[test] +fn styles_parse_and_urls_pin_the_commit() { + let index = index_of(&fixture().index).unwrap(); assert_eq!(parse_style("Extra Bold Italic"), Some((800, true))); assert_eq!(parse_style("semi-bold"), Some((600, false))); assert_eq!(parse_style("Condensed Bold"), None); diff --git a/crates/keymap/src/ui/view.rs b/crates/keymap/src/ui/view.rs index 7b46245a..6922d0ac 100644 --- a/crates/keymap/src/ui/view.rs +++ b/crates/keymap/src/ui/view.rs @@ -894,84 +894,8 @@ impl Draw<'_, '_> { let (g, _) = self.one_line(ui.painter(), &self.ordered(parts), label_w); ui.painter().galley(pos2(m.x(rect.min.x + 8.0, g.size().x), rect.center().y - g.size().y / 2.0), g, text_color); - // Shortcut column: chips, then "+". - let mut action = None; - let col = Rect::from_min_max(pos2(rect.max.x - shortcut_w - ICON - 4.0, rect.min.y), pos2(rect.max.x - ICON - 4.0, rect.max.y)); - let painter = ui.painter_at(m.rect(col)); - let mut x = col.min.x; - let rec = match self.recording_target() { - Some(Target::Cell { command, old }) if *command == r.command => Some(old.clone()), - _ => None, - }; - let chip_h = rect.height() - 4.0; - let mut cells: Vec<(Option, String, bool)> = - r.chips.iter().map(|c| (Some(c.binding.clone()), c.text.clone(), rec.as_ref() == Some(&Some(c.binding.clone())))).collect(); - if rec == Some(None) { - cells.push((None, String::new(), true)); - } - for (n, (binding, text, recording)) in cells.iter().enumerate() { - let shown = if *recording { self.recording_text() } else { text.clone() }; - let g = painter.layout_no_wrap(shown.clone(), font.clone(), text_color); - let w = g.size().x + 2.0 * CHIP_PAD; - let chip = m.rect(Rect::from_min_size(pos2(x, rect.min.y + 2.0), vec2(w, chip_h))); - x += w + CHIP_GAP; - let changed = r.chips.get(n).is_some_and(|c| c.changed); - let stroke = if *recording { - Stroke::new(1.5, self.style.accent) - } else if changed { - Stroke::new(1.0, self.style.changed) - } else { - v.widgets.inactive.bg_stroke - }; - let current_chip = selected && focused && n == self.state.chip; - painter.rect(chip, 3.0, v.widgets.inactive.weak_bg_fill, if current_chip { v.selection.stroke } else { stroke }, StrokeKind::Inside); - painter.galley(pos2(chip.min.x + CHIP_PAD, chip.center().y - g.size().y / 2.0), g, v.text_color()); - let waiting = self.state.recording.as_ref().is_some_and(|r| r.waiting.is_some()); - let label = if *recording && waiting { - fill(self.ui("{label}: {chord}"), &[("label", &r.label), ("chord", &shown)]) - } else if *recording { - fill(self.ui("{label}: press keys"), &[("label", &r.label)]) - } else { - fill(self.ui("{label}: {chord}"), &[("label", &r.label), ("chord", text)]) - }; - let resp = - ui.interact(chip.intersect(m.rect(col)), self.base.with(("cell", &r.key, n)), if editable { Sense::CLICK } else { Sense::hover() }); - resp.widget_info(|| WidgetInfo::labeled(Role::Button, editable, &label)); - self.tag(|| format!("shortcuts.cell.{}.{n}", r.key), chip, &label); - if resp.clicked() { - action = Some(RowAction::Record(r.command.clone(), binding.clone(), n)); - } - } - if cells.is_empty() { - // An empty cell to click for the first shortcut. - let cell = m.rect(Rect::from_min_max(pos2(col.min.x, rect.min.y), pos2(col.max.x - ICON - CHIP_GAP, rect.max.y))); - let label = fill(self.ui("{label}: {chord}"), &[("label", &r.label), ("chord", &self.ui("None"))]); - let resp = ui.interact(cell, self.base.with(("cell", &r.key, 0)), if editable { Sense::CLICK } else { Sense::hover() }); - resp.widget_info(|| WidgetInfo::labeled(Role::Button, editable, &label)); - self.tag(|| format!("shortcuts.cell.{}.0", r.key), cell, &label); - if resp.clicked() { - action = Some(RowAction::Record(r.command.clone(), None, 0)); - } - x = col.min.x; - } - if editable && r.chips.len() < MAX_BINDINGS && rec != Some(None) { - let plus = m.rect(Rect::from_min_size(pos2(x.min(col.max.x - ICON), rect.center().y - ICON / 2.0), Vec2::splat(ICON))); - let label = fill(self.ui("Add a shortcut to {label}"), &[("label", &r.label)]); - let resp = self.row_button(ui, plus, self.base.with(("add", &r.key)), &label, true, paint_plus); - self.tag(|| format!("shortcuts.add.{}", r.key), plus, &label); - if resp.clicked() { - action = Some(RowAction::Record(r.command.clone(), None, r.chips.len())); - } - } - if editable { - let reset = m.rect(Rect::from_min_size(pos2(rect.max.x - ICON - 2.0, rect.center().y - ICON / 2.0), Vec2::splat(ICON))); - let label = fill(self.ui("Restore the default for {label}"), &[("label", &r.label)]); - let resp = self.row_button(ui, reset, self.base.with(("reset", &r.key)), &label, r.changed, paint_reset); - self.tag(|| format!("shortcuts.reset.{}", r.key), reset, &label); - if resp.clicked() { - action = Some(RowAction::Reset(r.command.clone())); - } - } + let action = + self.shortcut_column(ui, RowShortcutLayout { row: r, rect, shortcut_w, mirror: m, editable, selected, focused, font, text_color }); let clicked = resp.clicked(); let mut tip = vec![self.vis(&r.label)]; @@ -987,6 +911,107 @@ impl Draw<'_, '_> { action.or_else(|| clicked.then(|| RowAction::Select(r.command.clone()))) } + fn shortcut_column(&self, ui: &mut Ui, layout: RowShortcutLayout<'_>) -> Option { + let RowShortcutLayout { row, rect, shortcut_w, mirror, editable, selected, focused, font, text_color } = layout; + let col = Rect::from_min_max(pos2(rect.max.x - shortcut_w - ICON - 4.0, rect.min.y), pos2(rect.max.x - ICON - 4.0, rect.max.y)); + let painter = ui.painter_at(mirror.rect(col)); + let mut x = col.min.x; + let rec = match self.recording_target() { + Some(Target::Cell { command, old }) if *command == row.command => Some(old.clone()), + _ => None, + }; + let mut cells: Vec<(Option, String, bool)> = + row.chips.iter().map(|chip| (Some(chip.binding.clone()), chip.text.clone(), rec.as_ref() == Some(&Some(chip.binding.clone())))).collect(); + if rec == Some(None) { + cells.push((None, String::new(), true)); + } + let mut action = self.draw_shortcut_chips( + ui, + ShortcutChipsLayout { painter: &painter, mirror, col, rect, row, cells: &cells, editable, selected, focused, font: &font, text_color }, + &mut x, + ); + if cells.is_empty() { + action = self.draw_empty_shortcut_cell(ui, mirror, col, rect, row, editable).or(action); + x = col.min.x; + } + if editable && row.chips.len() < MAX_BINDINGS && rec != Some(None) { + let plus = mirror.rect(Rect::from_min_size(pos2(x.min(col.max.x - ICON), rect.center().y - ICON / 2.0), Vec2::splat(ICON))); + let label = fill(self.ui("Add a shortcut to {label}"), &[("label", &row.label)]); + let resp = self.row_button(ui, plus, self.base.with(("add", &row.key)), &label, true, paint_plus); + self.tag(|| format!("shortcuts.add.{}", row.key), plus, &label); + if resp.clicked() { + action = Some(RowAction::Record(row.command.clone(), None, row.chips.len())); + } + } + if editable { + let reset = mirror.rect(Rect::from_min_size(pos2(rect.max.x - ICON - 2.0, rect.center().y - ICON / 2.0), Vec2::splat(ICON))); + let label = fill(self.ui("Restore the default for {label}"), &[("label", &row.label)]); + let resp = self.row_button(ui, reset, self.base.with(("reset", &row.key)), &label, row.changed, paint_reset); + self.tag(|| format!("shortcuts.reset.{}", row.key), reset, &label); + if resp.clicked() { + action = Some(RowAction::Reset(row.command.clone())); + } + } + action + } + + fn draw_shortcut_chips(&self, ui: &mut Ui, layout: ShortcutChipsLayout<'_>, x: &mut f32) -> Option { + let ShortcutChipsLayout { painter, mirror, col, rect, row, cells, editable, selected, focused, font, text_color } = layout; + let v = ui.visuals(); + let mut action = None; + for (n, (binding, text, recording)) in cells.iter().enumerate() { + let shown = if *recording { self.recording_text() } else { text.clone() }; + let g = painter.layout_no_wrap(shown.clone(), font.clone(), text_color); + let w = g.size().x + 2.0 * CHIP_PAD; + let chip = mirror.rect(Rect::from_min_size(pos2(*x, rect.min.y + 2.0), vec2(w, rect.height() - 4.0))); + *x += w + CHIP_GAP; + let changed = row.chips.get(n).is_some_and(|c| c.changed); + let stroke = if *recording { + Stroke::new(1.5, self.style.accent) + } else if changed { + Stroke::new(1.0, self.style.changed) + } else { + v.widgets.inactive.bg_stroke + }; + let current_chip = selected && focused && n == self.state.chip; + painter.rect(chip, 3.0, v.widgets.inactive.weak_bg_fill, if current_chip { v.selection.stroke } else { stroke }, StrokeKind::Inside); + painter.galley(pos2(chip.min.x + CHIP_PAD, chip.center().y - g.size().y / 2.0), g, v.text_color()); + let waiting = self.state.recording.as_ref().is_some_and(|r| r.waiting.is_some()); + let label = self.shortcut_accessibility_label(row, text, &shown, *recording, waiting); + let resp = ui.interact( + chip.intersect(mirror.rect(col)), + self.base.with(("cell", &row.key, n)), + if editable { Sense::CLICK } else { Sense::hover() }, + ); + resp.widget_info(|| WidgetInfo::labeled(Role::Button, editable, &label)); + self.tag(|| format!("shortcuts.cell.{}.{n}", row.key), chip, &label); + if resp.clicked() { + action = Some(RowAction::Record(row.command.clone(), binding.clone(), n)); + } + } + action + } + + fn shortcut_accessibility_label(&self, row: &Row, text: &str, shown: &str, recording: bool, waiting: bool) -> String { + if recording && waiting { + fill(self.ui("{label}: {chord}"), &[("label", &row.label), ("chord", shown)]) + } else if recording { + fill(self.ui("{label}: press keys"), &[("label", &row.label)]) + } else { + fill(self.ui("{label}: {chord}"), &[("label", &row.label), ("chord", text)]) + } + } + + fn draw_empty_shortcut_cell(&self, ui: &mut Ui, mirror: Mirror, col: Rect, rect: Rect, row: &Row, editable: bool) -> Option { + let cell = mirror.rect(Rect::from_min_max(pos2(col.min.x, rect.min.y), pos2(col.max.x - ICON - CHIP_GAP, rect.max.y))); + let none = self.ui("None"); + let label = fill(self.ui("{label}: {chord}"), &[("label", &row.label), ("chord", &none)]); + let resp = ui.interact(cell, self.base.with(("cell", &row.key, 0)), if editable { Sense::CLICK } else { Sense::hover() }); + resp.widget_info(|| WidgetInfo::labeled(Role::Button, editable, &label)); + self.tag(|| format!("shortcuts.cell.{}.0", row.key), cell, &label); + resp.clicked().then(|| RowAction::Record(row.command.clone(), None, 0)) + } + /// A read-only row from `Options::fixed`, struck through when a command took its chord. fn fixed_row(&self, ui: &mut Ui, rect: Rect, f: &FixedLine, shortcut_w: f32) { let v = ui.visuals(); @@ -1457,6 +1482,32 @@ struct Mirror { sum: f32, } +struct RowShortcutLayout<'a> { + row: &'a Row, + rect: Rect, + shortcut_w: f32, + mirror: Mirror, + editable: bool, + selected: bool, + focused: bool, + font: egui::FontId, + text_color: Color32, +} + +struct ShortcutChipsLayout<'a> { + painter: &'a Painter, + mirror: Mirror, + col: Rect, + rect: Rect, + row: &'a Row, + cells: &'a [(Option, String, bool)], + editable: bool, + selected: bool, + focused: bool, + font: &'a egui::FontId, + text_color: Color32, +} + impl Mirror { fn rect(self, r: Rect) -> Rect { if self.on { Rect::from_x_y_ranges((self.sum - r.max.x)..=(self.sum - r.min.x), r.y_range()) } else { r } diff --git a/crates/media/src/isobmff/codec.rs b/crates/media/src/isobmff/codec.rs index a0f63962..b98fec6c 100644 --- a/crates/media/src/isobmff/codec.rs +++ b/crates/media/src/isobmff/codec.rs @@ -1379,95 +1379,7 @@ impl SampleEntry { return Ok(()); } if let Some(v) = &self.video { - let m = b.start(&self.format.0); - b.zeros(6); - b.u16(self.data_reference_index); - b.u16(0); - b.u16(0); - b.u32(v.vendor.as_u32()); - b.u32(v.temporal_quality); - b.u32(v.spatial_quality); - b.u16(v.width); - b.u16(v.height); - b.u32(v.horiz_resolution); - b.u32(v.vert_resolution); - b.u32(0); - b.u16(v.frame_count); - let name = v.compressor_name.as_bytes(); - let n = name.len().min(31); - let mut nb = [0u8; 32]; - nb[0] = n as u8; - nb[1..1 + n].copy_from_slice(&name[..n]); - b.bytes(&nb); - b.u16(v.depth); - b.i16(-1); - match &self.codec { - CodecConfig::Avc(c) => b.leaf(b"avcC", &c.to_bytes()), - CodecConfig::Hevc(c) => b.leaf(b"hvcC", &c.to_bytes()), - CodecConfig::Av1(c) => b.leaf(b"av1C", &c.to_bytes()), - CodecConfig::Vp9(c) => b.leaf(b"vpcC", &c.to_bytes()), - CodecConfig::Apv(c) => b.leaf(b"apvC", &c.to_bytes()), - _ => {} - } - if let Some(g) = v.gamma { - b.leaf(b"gama", &g.to_be_bytes()); - } - if let Some(fi) = v.field_info { - b.leaf(b"fiel", &[fi.fields, fi.detail]); - } - if let Some(ci) = &v.color { - let m = b.start(b"colr"); - match ci { - ColorInfo::Nclx { primaries, transfer, matrix, full_range } => { - b.bytes(b"nclx"); - b.u16(*primaries); - b.u16(*transfer); - b.u16(*matrix); - b.u8(if *full_range { 0x80 } else { 0 }); - } - ColorInfo::Nclc { primaries, transfer, matrix } => { - b.bytes(b"nclc"); - b.u16(*primaries); - b.u16(*transfer); - b.u16(*matrix); - } - ColorInfo::Icc { kind, profile } => { - b.bytes(&kind.0); - b.bytes(profile); - } - } - b.end(m); - } - if let Some(md) = &v.mastering_display { - b.leaf(b"mdcv", &md.to_bytes()); - } - if let Some((cll, fall)) = v.content_light { - let mut d = cll.to_be_bytes().to_vec(); - d.extend_from_slice(&fall.to_be_bytes()); - b.leaf(b"clli", &d); - } - if let Some((h, vv)) = v.pixel_aspect { - let m = b.start(b"pasp"); - b.u32(h); - b.u32(vv); - b.end(m); - } - if let Some(c) = v.clean_aperture { - let m = b.start(b"clap"); - b.u32(c.width.0); - b.u32(c.width.1); - b.u32(c.height.0); - b.u32(c.height.1); - b.i32(c.horiz_offset.0); - b.u32(c.horiz_offset.1); - b.i32(c.vert_offset.0); - b.u32(c.vert_offset.1); - b.end(m); - } - if let Some(br) = self.bitrate { - write_btrt(b, br); - } - b.end(m); + self.write_video(b, v); return Ok(()); } let a = self.audio.clone().ok_or_else(|| Error::Mux("sample entry has neither video nor audio params".into()))?; @@ -1581,6 +1493,118 @@ impl SampleEntry { b.end(m); Ok(()) } + + fn write_video(&self, b: &mut BoxBuf, v: &VideoParams) { + let m = b.start(&self.format.0); + b.zeros(6); + b.u16(self.data_reference_index); + b.u16(0); + b.u16(0); + b.u32(v.vendor.as_u32()); + b.u32(v.temporal_quality); + b.u32(v.spatial_quality); + b.u16(v.width); + b.u16(v.height); + b.u32(v.horiz_resolution); + b.u32(v.vert_resolution); + b.u32(0); + b.u16(v.frame_count); + let name = v.compressor_name.as_bytes(); + let n = name.len().min(31); + let mut nb = [0u8; 32]; + nb[0] = n as u8; + nb[1..1 + n].copy_from_slice(&name[..n]); + b.bytes(&nb); + b.u16(v.depth); + b.i16(-1); + write_video_codec_config(b, &self.codec); + write_video_descriptive_boxes(b, v); + if let Some(br) = self.bitrate { + write_btrt(b, br); + } + b.end(m); + } +} + +fn write_video_codec_config(b: &mut BoxBuf, codec: &CodecConfig) { + match codec { + CodecConfig::Avc(config) => b.leaf(b"avcC", &config.to_bytes()), + CodecConfig::Hevc(config) => b.leaf(b"hvcC", &config.to_bytes()), + CodecConfig::Av1(config) => b.leaf(b"av1C", &config.to_bytes()), + CodecConfig::Vp9(config) => b.leaf(b"vpcC", &config.to_bytes()), + CodecConfig::Apv(config) => b.leaf(b"apvC", &config.to_bytes()), + _ => {} + } +} + +fn write_video_descriptive_boxes(b: &mut BoxBuf, v: &VideoParams) { + if let Some(gamma) = v.gamma { + b.leaf(b"gama", &gamma.to_be_bytes()); + } + if let Some(field_info) = v.field_info { + b.leaf(b"fiel", &[field_info.fields, field_info.detail]); + } + if let Some(color) = &v.color { + write_color_box(b, color); + } + if let Some(display) = &v.mastering_display { + b.leaf(b"mdcv", &display.to_bytes()); + } + if let Some((cll, fall)) = v.content_light { + let mut data = cll.to_be_bytes().to_vec(); + data.extend_from_slice(&fall.to_be_bytes()); + b.leaf(b"clli", &data); + } + write_pixel_aspect_box(b, v.pixel_aspect); + write_clean_aperture_box(b, v.clean_aperture); +} + +fn write_color_box(b: &mut BoxBuf, color: &ColorInfo) { + let m = b.start(b"colr"); + match color { + ColorInfo::Nclx { primaries, transfer, matrix, full_range } => { + b.bytes(b"nclx"); + b.u16(*primaries); + b.u16(*transfer); + b.u16(*matrix); + b.u8(if *full_range { 0x80 } else { 0 }); + } + ColorInfo::Nclc { primaries, transfer, matrix } => { + b.bytes(b"nclc"); + b.u16(*primaries); + b.u16(*transfer); + b.u16(*matrix); + } + ColorInfo::Icc { kind, profile } => { + b.bytes(&kind.0); + b.bytes(profile); + } + } + b.end(m); +} + +fn write_pixel_aspect_box(b: &mut BoxBuf, pixel_aspect: Option<(u32, u32)>) { + if let Some((horizontal, vertical)) = pixel_aspect { + let m = b.start(b"pasp"); + b.u32(horizontal); + b.u32(vertical); + b.end(m); + } +} + +fn write_clean_aperture_box(b: &mut BoxBuf, clean_aperture: Option) { + if let Some(aperture) = clean_aperture { + let m = b.start(b"clap"); + b.u32(aperture.width.0); + b.u32(aperture.width.1); + b.u32(aperture.height.0); + b.u32(aperture.height.1); + b.i32(aperture.horiz_offset.0); + b.u32(aperture.horiz_offset.1); + b.i32(aperture.vert_offset.0); + b.u32(aperture.vert_offset.1); + b.end(m); + } } fn write_btrt(b: &mut BoxBuf, br: BitRate) { diff --git a/crates/media/src/isobmff/demux.rs b/crates/media/src/isobmff/demux.rs index f3c0d78f..8f7c413d 100644 --- a/crates/media/src/isobmff/demux.rs +++ b/crates/media/src/isobmff/demux.rs @@ -604,7 +604,19 @@ impl SttsCursor<'_> { } } -fn parse_stbl(stbl: &[u8], t: &mut Track, is_qt: bool, file_len: u64, budget: &mut SampleBudget) -> Result<()> { +struct StblTables { + stts: Vec<(u32, u32)>, + ctts: Vec<(u32, u32)>, + stss: Option>, + stsc: Vec<(u32, u32, u32)>, + const_size: u32, + sample_count: usize, + sizes: Vec, + chunks: Vec, + sample_groups: Vec, +} + +fn parse_stbl_tables(stbl: &[u8], t: &mut Track, is_qt: bool, file_len: u64) -> Result { let mut stts: Vec<(u32, u32)> = Vec::new(); let mut ctts: Vec<(u32, u32)> = Vec::new(); let mut stss: Option> = None; @@ -730,8 +742,15 @@ fn parse_stbl(stbl: &[u8], t: &mut Track, is_qt: bool, file_len: u64, budget: &m _ => {} } } - t.sample_groups = + let sample_groups = sbgp.into_iter().map(|(gt, runs)| SampleGroup { grouping_type: gt, runs, descriptions: sgpd.remove(>).unwrap_or_default() }).collect(); + Ok(StblTables { stts, ctts, stss, stsc, const_size, sample_count, sizes, chunks, sample_groups }) +} + +fn parse_stbl(stbl: &[u8], t: &mut Track, is_qt: bool, file_len: u64, budget: &mut SampleBudget) -> Result<()> { + let StblTables { stts, ctts, stss, stsc, const_size, mut sample_count, sizes, chunks, sample_groups } = + parse_stbl_tables(stbl, t, is_qt, file_len)?; + t.sample_groups = sample_groups; if sample_count == 0 || chunks.is_empty() || stsc.is_empty() { return Ok(()); } diff --git a/crates/media/src/isobmff/mux.rs b/crates/media/src/isobmff/mux.rs index 8f50d1f1..836aaaed 100644 --- a/crates/media/src/isobmff/mux.rs +++ b/crates/media/src/isobmff/mux.rs @@ -422,6 +422,14 @@ fn write_trak(b: &mut BoxBuf, t: &WTrack, movie_ts: u32, qt: bool, shift: u64, f b.end(u); b.end(r); b.end(d); + write_stbl(b, t, qt, shift, force_co64)?; + b.end(minf); + b.end(mdia); + b.end(trak); + Ok(()) +} + +fn write_stbl(b: &mut BoxBuf, t: &WTrack, qt: bool, shift: u64, force_co64: bool) -> Result<()> { let stbl = b.start(b"stbl"); let m = b.start_full(b"stsd", 0, 0); b.u32(1); @@ -503,9 +511,6 @@ fn write_trak(b: &mut BoxBuf, t: &WTrack, movie_ts: u32, qt: bool, shift: u64, f } b.end(m); b.end(stbl); - b.end(minf); - b.end(mdia); - b.end(trak); Ok(()) } diff --git a/crates/media/src/matroska/demux.rs b/crates/media/src/matroska/demux.rs index 15ca9dbd..253f613c 100644 --- a/crates/media/src/matroska/demux.rs +++ b/crates/media/src/matroska/demux.rs @@ -4,8 +4,16 @@ use std::collections::{HashMap, VecDeque}; use crate::matroska::ebml::{self, Children, Lacing, Size, parse_lacing, read_id, read_size}; use crate::matroska::error::{Error, Result}; -use crate::matroska::ids::*; -use crate::matroska::meta::*; +use crate::matroska::ids::{ + ATTACHED_FILE, ATTACHMENTS, BLOCK, BLOCK_ADD_ID, BLOCK_ADDITIONAL, BLOCK_ADDITIONS, BLOCK_DURATION, BLOCK_GROUP, BLOCK_MORE, CHAPTERS, CLUSTER, + CRC32, CUES, DISCARD_PADDING, DOC_TYPE, DOC_TYPE_READ_VERSION, DOC_TYPE_VERSION, EBML, EBML_MAX_ID_LENGTH, EBML_MAX_SIZE_LENGTH, FILE_DATA, + FILE_DESCRIPTION, FILE_MEDIA_TYPE, FILE_NAME, FILE_UID, INFO, REFERENCE_BLOCK, SEEK_HEAD, SEGMENT, SIMPLE_BLOCK, TAGS, TIMESTAMP, TRACK_ENTRY, + TRACKS, VOID, is_top_level, +}; +use crate::matroska::meta::{ + Attachment, ClusterInfo, CuePoint, Edition, SeekEntry, SegmentInfo, Tag, parse_chapters, parse_cues, parse_info, parse_seekhead, parse_tags, + parse_tracks, +}; use crate::matroska::source::{ByteSource, ReadSeekSource}; use crate::matroska::track::{Sample, Track, TrackKind, gcd}; diff --git a/crates/media/src/matroska/meta.rs b/crates/media/src/matroska/meta.rs index 8cf80248..d0356a6b 100644 --- a/crates/media/src/matroska/meta.rs +++ b/crates/media/src/matroska/meta.rs @@ -3,7 +3,22 @@ use crate::matroska::codec::map_codec; use crate::matroska::ebml::{Children, float, int, string, uint}; -use crate::matroska::ids::*; +use crate::matroska::ids::{ + ALPHA_MODE, AUDIO, BIT_DEPTH, BITS_PER_CHANNEL, CB_SUBSAMPLING_HORZ, CB_SUBSAMPLING_VERT, CHANNELS, CHAP_COUNTRY, CHAP_LANGUAGE, + CHAP_LANGUAGE_BCP47, CHAP_STRING, CHAPTER_ATOM, CHAPTER_DISPLAY, CHAPTER_FLAG_ENABLED, CHAPTER_FLAG_HIDDEN, CHAPTER_STRING_UID, CHAPTER_TIME_END, + CHAPTER_TIME_START, CHAPTER_UID, CHROMA_SITING_HORZ, CHROMA_SITING_VERT, CHROMA_SUBSAMPLING_HORZ, CHROMA_SUBSAMPLING_VERT, CODEC_DELAY, CODEC_ID, + CODEC_NAME, CODEC_PRIVATE, COLOUR, COLOUR_SPACE, CONTENT_COMP_ALGO, CONTENT_COMP_SETTINGS, CONTENT_COMPRESSION, CONTENT_ENCODING, + CONTENT_ENCODING_ORDER, CONTENT_ENCODING_SCOPE, CONTENT_ENCODING_TYPE, CONTENT_ENCODINGS, CUE_BLOCK_NUMBER, CUE_CLUSTER_POSITION, CUE_DURATION, + CUE_POINT, CUE_RELATIVE_POSITION, CUE_TIME, CUE_TRACK, CUE_TRACK_POSITIONS, DATE_UTC, DEFAULT_DURATION, DISPLAY_HEIGHT, DISPLAY_UNIT, + DISPLAY_WIDTH, DURATION, EDITION_ENTRY, EDITION_FLAG_DEFAULT, EDITION_FLAG_HIDDEN, EDITION_FLAG_ORDERED, EDITION_UID, FIELD_ORDER, FLAG_DEFAULT, + FLAG_ENABLED, FLAG_FORCED, FLAG_HEARING_IMPAIRED, FLAG_INTERLACED, FLAG_LACING, LANGUAGE, LANGUAGE_BCP47, LUMINANCE_MAX, LUMINANCE_MIN, + MASTERING_METADATA, MATRIX_COEFFICIENTS, MAX_CLL, MAX_FALL, MUXING_APP, NAME, OUTPUT_SAMPLING_FREQUENCY, PIXEL_CROP_BOTTOM, PIXEL_CROP_LEFT, + PIXEL_CROP_RIGHT, PIXEL_CROP_TOP, PIXEL_HEIGHT, PIXEL_WIDTH, PRIMARIES, PRIMARY_B_X, PRIMARY_B_Y, PRIMARY_G_X, PRIMARY_G_Y, PRIMARY_R_X, + PRIMARY_R_Y, PROJECTION, PROJECTION_POSE_PITCH, PROJECTION_POSE_ROLL, PROJECTION_POSE_YAW, PROJECTION_TYPE, RANGE, SAMPLING_FREQUENCY, SEEK, + SEEK_ID, SEEK_POSITION, SEEK_PRE_ROLL, SEGMENT_UUID, SIMPLE_TAG, STEREO_MODE, TAG, TAG_ATTACHMENT_UID, TAG_BINARY, TAG_CHAPTER_UID, TAG_DEFAULT, + TAG_EDITION_UID, TAG_LANGUAGE, TAG_NAME, TAG_STRING, TAG_TRACK_UID, TARGET_TYPE, TARGET_TYPE_VALUE, TARGETS, TIMESTAMP_SCALE, TITLE, TRACK_ENTRY, + TRACK_NUMBER, TRACK_TYPE, TRACK_UID, TRANSFER_CHARACTERISTICS, VIDEO, WHITE_POINT_X, WHITE_POINT_Y, WRITING_APP, +}; use crate::matroska::track::{AudioInfo, Color, ContentEncoding, MasteringMetadata, Projection, Track, TrackKind, VideoInfo, gcd}; // A malformed Matroska file may contain arbitrarily deep ChapterAtom and SimpleTag chains. diff --git a/crates/media/src/matroska/mux.rs b/crates/media/src/matroska/mux.rs index f481ceac..7d26a8f1 100644 --- a/crates/media/src/matroska/mux.rs +++ b/crates/media/src/matroska/mux.rs @@ -6,7 +6,13 @@ use std::io::{Seek, SeekFrom, Write}; use crate::matroska::ebml::{write_id, write_size, write_unknown_size}; use crate::matroska::error::{Error, Result}; -use crate::matroska::ids::*; +use crate::matroska::ids::{ + AUDIO, BIT_DEPTH, BLOCK, BLOCK_DURATION, BLOCK_GROUP, CHANNELS, CLUSTER, CODEC_DELAY, CODEC_ID, CODEC_PRIVATE, CUE_CLUSTER_POSITION, CUE_POINT, + CUE_TIME, CUE_TRACK, CUE_TRACK_POSITIONS, CUES, DEFAULT_DURATION, DOC_TYPE, DOC_TYPE_READ_VERSION, DOC_TYPE_VERSION, DURATION, EBML, + EBML_MAX_ID_LENGTH, EBML_MAX_SIZE_LENGTH, EBML_READ_VERSION, EBML_VERSION, FLAG_LACING, INFO, LANGUAGE, MUXING_APP, NAME, PIXEL_HEIGHT, + PIXEL_WIDTH, REFERENCE_BLOCK, SAMPLING_FREQUENCY, SEEK, SEEK_HEAD, SEEK_ID, SEEK_POSITION, SEEK_PRE_ROLL, SEGMENT, SIMPLE_BLOCK, TIMESTAMP, + TIMESTAMP_SCALE, TITLE, TRACK_ENTRY, TRACK_NUMBER, TRACK_TYPE, TRACK_UID, TRACKS, VIDEO, VOID, WRITING_APP, +}; use crate::matroska::track::TrackKind; /// Lacing used for audio tracks. Only frames of a track with a DefaultDuration lace, and a lace diff --git a/crates/media/src/matroska/synthetic_tests.rs b/crates/media/src/matroska/synthetic_tests.rs index 3ec0674f..0fbe5fae 100644 --- a/crates/media/src/matroska/synthetic_tests.rs +++ b/crates/media/src/matroska/synthetic_tests.rs @@ -8,7 +8,7 @@ use crate::matroska::error::Error; use crate::matroska::ids::*; use crate::matroska::mux::{el, el_float, el_str, el_uint}; use crate::matroska::track::ContentEncoding; -use crate::matroska::{ByteSource, Demuxer, OpenOptions, Packet, open_with}; +use crate::matroska::{ByteSource, Demuxer, MkvFile, OpenOptions, Packet, open_with}; fn block(track: u8, rel: i16, flags: u8, data: &[u8]) -> Vec { let mut b = vec![0x80 | track]; @@ -149,71 +149,81 @@ fn synthetic() -> Vec { f } -#[test] -fn synthetic_file() { - let bytes = synthetic(); - let f = open_with(&bytes[..], &OpenOptions { index: true, verify_crc: true }).unwrap(); +fn assert_synthetic_metadata(f: &MkvFile, bytes: &[u8]) { assert!(f.live); assert!(f.warnings.is_empty(), "{:?}", f.warnings); assert_eq!((f.crc_checked, f.crc_errors.len()), (1, 0)); assert_eq!(f.info.title.as_deref(), Some("Synthetic")); - let v = &f.tracks[0]; - assert!(matches!(&v.codec, Codec::Avc { avcc } if avcc[1] == 0x64)); - let vi = v.video.as_ref().unwrap(); - assert_eq!(vi.pixel_aspect(), (4, 3)); - assert_eq!(vi.projection.as_ref().map(|p| p.roll), Some(-90.0)); - assert_eq!(vi.display_rotation(), Some(1)); - let c = vi.color.as_ref().unwrap(); - assert!(c.full_range()); + let video = &f.tracks[0]; + assert!(matches!(&video.codec, Codec::Avc { avcc } if avcc[1] == 0x64)); + let info = video.video.as_ref().unwrap(); + assert_eq!(info.pixel_aspect(), (4, 3)); + assert_eq!(info.projection.as_ref().map(|projection| projection.roll), Some(-90.0)); + assert_eq!(info.display_rotation(), Some(1)); + let color = info.color.as_ref().unwrap(); + assert!(color.full_range()); assert_eq!( - (c.matrix_coefficients, c.transfer_characteristics, c.primaries, c.max_cll, c.max_fall), + (color.matrix_coefficients, color.transfer_characteristics, color.primaries, color.max_cll, color.max_fall), (Some(9), Some(16), Some(9), Some(1000), Some(400)) ); - let m = c.mastering.as_ref().unwrap(); - assert_eq!(m.primaries[1], (0.17, 0.797)); - assert_eq!(m.white_point, (0.3127, 0.329)); - assert_eq!((m.luminance_max, m.luminance_min), (1000.0, 0.0001)); - assert_eq!(v.encodings, vec![ContentEncoding::HeaderStripping { bytes: vec![0, 0, 0, 1], scope: 1 }]); - // legacy AAC ID: ASC synthesized with explicit SBR signaling (LC, 24 kHz, stereo → 48 kHz) + let mastering = color.mastering.as_ref().unwrap(); + assert_eq!(mastering.primaries[1], (0.17, 0.797)); + assert_eq!(mastering.white_point, (0.3127, 0.329)); + assert_eq!((mastering.luminance_max, mastering.luminance_min), (1000.0, 0.0001)); + assert_eq!(video.encodings, vec![ContentEncoding::HeaderStripping { bytes: vec![0, 0, 0, 1], scope: 1 }]); + // Legacy AAC ID synthesizes explicit SBR signaling (LC, 24 kHz, stereo to 48 kHz). assert_eq!(f.tracks[1].codec, Codec::Aac { asc: vec![0x13, 0x10, 0x56, 0xE5, 0x98] }); assert_eq!(f.tracks[1].language, "ger"); assert_eq!(f.chapters[0].chapters[0].title(), Some("Intro")); assert_eq!(f.chapters[0].chapters[0].end_ns, Some(40_000_000)); assert_eq!(f.attachments.len(), 1); - let at = &f.attachments[0]; - assert_eq!((at.name.as_str(), at.media_type.as_str(), at.uid), ("font.ttf", "font/ttf", 77)); - assert_eq!(&bytes[at.data_offset as usize..][..100], &[9; 100]); + let attachment = &f.attachments[0]; + assert_eq!((attachment.name.as_str(), attachment.media_type.as_str(), attachment.uid), ("font.ttf", "font/ttf", 77)); + assert_eq!(&bytes[attachment.data_offset as usize..][..100], &[9; 100]); assert_eq!(f.tags[0].targets.track_uids, vec![11]); assert_eq!(f.tags[0].simple[0].string.as_deref(), Some("Nobody")); assert_eq!(f.clusters.len(), 2); - assert_eq!(v.samples.len(), 3); - assert_eq!(v.keyframes(), &[0, 2]); + assert_eq!(video.samples.len(), 3); + assert_eq!(video.keyframes(), &[0, 2]); +} - let d = Demuxer::from_slice(&bytes).unwrap(); - let p: Vec = d.map(|p| p.unwrap()).collect(); - assert_eq!(p.len(), 4); - assert_eq!((p[0].track, p[0].pts, p[0].keyframe, p[0].duration), (0, 1000, true, 40)); - assert_eq!(p[0].data, [0, 0, 0, 1, 0x65, 1, 2]); - assert_eq!((p[1].track, p[1].pts, p[1].data.len()), (1, 1005, 10)); - assert_eq!((p[2].pts, p[2].keyframe, p[2].duration), (1040, false, 80)); - assert_eq!(p[2].data, [0, 0, 0, 1, 0x41, 3]); - assert_eq!((p[3].pts, p[3].pts_ns), (1120, 1_120_000_000)); - assert_eq!(f.read_sample(&bytes[..], 0, 1).unwrap(), p[2].data); +fn assert_synthetic_packets(f: &MkvFile, bytes: &[u8]) { + let packets: Vec = Demuxer::from_slice(bytes).unwrap().map(|packet| packet.unwrap()).collect(); + assert_eq!(packets.len(), 4); + assert_eq!((packets[0].track, packets[0].pts, packets[0].keyframe, packets[0].duration), (0, 1000, true, 40)); + assert_eq!(packets[0].data, [0, 0, 0, 1, 0x65, 1, 2]); + assert_eq!((packets[1].track, packets[1].pts, packets[1].data.len()), (1, 1005, 10)); + assert_eq!((packets[2].pts, packets[2].keyframe, packets[2].duration), (1040, false, 80)); + assert_eq!(packets[2].data, [0, 0, 0, 1, 0x41, 3]); + assert_eq!((packets[3].pts, packets[3].pts_ns), (1120, 1_120_000_000)); + assert_eq!(f.read_sample(bytes, 0, 1).unwrap(), packets[2].data); +} - // CRC mismatch is reported, not fatal - let mut bad = bytes.clone(); - let title = bad.windows(9).position(|w| w == b"Synthetic").unwrap(); - bad[title] = b's'; - let f = open_with(&bad[..], &OpenOptions { index: true, verify_crc: true }).unwrap(); - assert_eq!(f.crc_errors.len(), 1); +fn assert_crc_mismatch_is_reported(bytes: &[u8]) { + let mut corrupted = bytes.to_vec(); + let title = corrupted.windows(9).position(|window| window == b"Synthetic").unwrap(); + corrupted[title] = b's'; + let file = open_with(&corrupted[..], &OpenOptions { index: true, verify_crc: true }).unwrap(); + assert_eq!(file.crc_errors.len(), 1); +} - // seeking without index or cues builds the index - let mut d = Demuxer::with_options(&bytes[..], OpenOptions { index: false, verify_crc: false }).unwrap(); - assert_eq!(d.file().tracks.len(), 2); - assert_eq!(d.seek(0, 1_100_000_000).unwrap().pts, 1000); - assert_eq!(d.seek(0, 1_130_000_000).unwrap().pts, 1120); - assert_eq!(d.next_packet().unwrap().unwrap().pts, 1120); - assert!(d.file().indexed); +fn assert_seek_builds_an_index(bytes: &[u8]) { + let mut demuxer = Demuxer::with_options(bytes, OpenOptions { index: false, verify_crc: false }).unwrap(); + assert_eq!(demuxer.file().tracks.len(), 2); + assert_eq!(demuxer.seek(0, 1_100_000_000).unwrap().pts, 1000); + assert_eq!(demuxer.seek(0, 1_130_000_000).unwrap().pts, 1120); + assert_eq!(demuxer.next_packet().unwrap().unwrap().pts, 1120); + assert!(demuxer.file().indexed); +} + +#[test] +fn synthetic_file() { + let bytes = synthetic(); + let file = open_with(&bytes[..], &OpenOptions { index: true, verify_crc: true }).unwrap(); + assert_synthetic_metadata(&file, &bytes); + assert_synthetic_packets(&file, &bytes); + assert_crc_mismatch_is_reported(&bytes); + assert_seek_builds_an_index(&bytes); } /// One video track and one cluster at timestamp `ts` holding `content`. @@ -317,7 +327,7 @@ fn cue_position_past_the_file_falls_back_to_the_index() { let mut el_cues = Vec::new(); el(&mut el_cues, CUES, &cues); bytes.splice(at..at, el_cues); - let mut d = Demuxer::with_options(&bytes[..], OpenOptions { index: false, verify_crc: false }).unwrap(); + let mut d = Demuxer::with_options(bytes, OpenOptions { index: false, verify_crc: false }).unwrap(); assert_eq!(d.file().cues.len(), 1); assert_eq!(d.seek(0, 0).unwrap().pts, 1000); } diff --git a/crates/media/tests/oracle_matroska.rs b/crates/media/tests/oracle_matroska.rs index d4619803..c7476aaa 100644 --- a/crates/media/tests/oracle_matroska.rs +++ b/crates/media/tests/oracle_matroska.rs @@ -10,90 +10,116 @@ use mkv::*; use serde_json::Value; use std::path::Path; -/// Compare every packet (file order) and stream parameter against ffprobe. -fn compare(ffprobe: &Path, path: &Path) -> (MkvFile, Vec) { - let json = ffprobe_json(ffprobe, path); - let bytes = std::fs::read(path).unwrap(); - let mut d = Demuxer::from_slice(&bytes).unwrap(); - let name = path.file_name().unwrap().to_string_lossy().to_string(); - let file = d.file().clone(); +fn assert_streams_match(json: &Value, file: &MkvFile, name: &str) { let streams = json["streams"].as_array().unwrap(); assert_eq!(streams.len(), file.tracks.len(), "{name}: stream count"); - for (i, st) in streams.iter().enumerate() { - let t = &file.tracks[i]; - assert_eq!(st["codec_name"].as_str().unwrap_or(""), t.codec.name(), "{name}: track {i} codec ({})", t.codec_id); - assert_eq!(st["time_base"].as_str(), Some(format!("{}/{}", t.timebase.0, t.timebase.1).as_str()), "{name}: time_base"); - match t.kind { + for (index, stream) in streams.iter().enumerate() { + let track = &file.tracks[index]; + assert_eq!(stream["codec_name"].as_str().unwrap_or(""), track.codec.name(), "{name}: track {index} codec ({})", track.codec_id); + assert_eq!(stream["time_base"].as_str(), Some(format!("{}/{}", track.timebase.0, track.timebase.1).as_str()), "{name}: time_base"); + match track.kind { TrackKind::Video => { - let v = t.video.as_ref().unwrap(); - assert_eq!(int(&st["width"]), Some(v.pixel_width as i64), "{name}: width"); - assert_eq!(int(&st["height"]), Some(v.pixel_height as i64), "{name}: height"); + let video = track.video.as_ref().unwrap(); + assert_eq!(int(&stream["width"]), Some(video.pixel_width as i64), "{name}: width"); + assert_eq!(int(&stream["height"]), Some(video.pixel_height as i64), "{name}: height"); } TrackKind::Audio => { - let a = t.audio.as_ref().unwrap(); - assert_eq!(int(&st["sample_rate"]), Some(a.sampling_frequency as i64), "{name}: sample_rate"); - assert_eq!(int(&st["channels"]), Some(a.channels as i64), "{name}: channels"); + let audio = track.audio.as_ref().unwrap(); + assert_eq!(int(&stream["sample_rate"]), Some(audio.sampling_frequency as i64), "{name}: sample_rate"); + assert_eq!(int(&stream["channels"]), Some(audio.channels as i64), "{name}: channels"); } _ => {} } } - let packets: Vec = d.by_ref().collect::>().unwrap(); - let ff = json["packets"].as_array().unwrap(); - assert_eq!(packets.len(), ff.len(), "{name}: packet count"); - let (mut pts_checked, mut dur_checked) = (0, 0); - for (i, (p, f)) in packets.iter().zip(ff).enumerate() { - let ctx = format!("{name}: packet {i} (track {})", p.track); - assert_eq!(int(&f["stream_index"]), Some(p.track as i64), "{ctx}: stream"); - if file.tracks[p.track].codec_id.starts_with("D_WEBVTT") { - // WebM WebVTT blocks carry "id\nsettings\npayload"; FFmpeg moves id/settings to side data - assert!(p.data.len() as i64 >= int(&f["size"]).unwrap(), "{ctx}: size"); +} + +fn assert_packets_match(json: &Value, file: &MkvFile, packets: &[Packet], name: &str) { + let ffprobe_packets = json["packets"].as_array().unwrap(); + assert_eq!(packets.len(), ffprobe_packets.len(), "{name}: packet count"); + let (mut pts_checked, mut duration_checked) = (0, 0); + for (index, (packet, ffprobe_packet)) in packets.iter().zip(ffprobe_packets).enumerate() { + let context = format!("{name}: packet {index} (track {})", packet.track); + assert_eq!(int(&ffprobe_packet["stream_index"]), Some(packet.track as i64), "{context}: stream"); + if file.tracks[packet.track].codec_id.starts_with("D_WEBVTT") { + // WebM WebVTT blocks carry "id\nsettings\npayload"; FFmpeg moves id/settings to side data. + assert!(packet.data.len() as i64 >= int(&ffprobe_packet["size"]).unwrap(), "{context}: size"); } else { - assert_eq!(int(&f["size"]), Some(p.data.len() as i64), "{ctx}: size"); - } - let key = f["flags"].as_str().unwrap_or("").starts_with('K'); - assert_eq!(key, p.keyframe, "{ctx}: keyframe"); - if let Some(pts) = int(&f["pts"]) - && !(p.lace > 0 && p.duration == 0) - { - // FFmpeg truncates the per-lace duration to whole ticks; we place laces exactly - // (rounded once), so later laces may differ by up to one tick per lace. - assert!((pts - p.pts).abs() <= p.lace as i64, "{ctx}: pts ffprobe {pts} vs {}", p.pts); - pts_checked += 1; - } - // The last audio or video frame of a track cut by `-t` is shorter than a codec frame. - // ffmpeg 8 writes a shortened BlockDuration there and reports the codec frame duration; a - // remux by our writer stores DefaultDuration and ffprobe reports the shortened one. So - // that frame's duration is not compared. A subtitle's duration is its cue's, and is. - let last_of_track = packets[i + 1..].iter().all(|q| q.track != p.track); - if let Some(dur) = int(&f["duration"]) - && p.duration != 0 - && (!last_of_track || file.tracks[p.track].kind == TrackKind::Subtitle) - { - assert!((dur - p.duration as i64).abs() <= 1, "{ctx}: duration ffprobe {dur} vs {}", p.duration); - dur_checked += 1; + assert_eq!(int(&ffprobe_packet["size"]), Some(packet.data.len() as i64), "{context}: size"); } + assert_eq!(ffprobe_packet["flags"].as_str().unwrap_or("").starts_with('K'), packet.keyframe, "{context}: keyframe"); + compare_packet_pts(ffprobe_packet, packet, &context, &mut pts_checked); + compare_packet_duration(ffprobe_packet, packet, packets, file, &context, index, &mut duration_checked); } assert!(pts_checked > 0, "{name}: no pts compared"); - eprintln!("{name}: {} packets, {pts_checked} pts and {dur_checked} durations compared", packets.len()); - // the sample index agrees with the packet stream - for (ti, t) in file.tracks.iter().enumerate() { - let pk: Vec<&Packet> = packets.iter().filter(|p| p.track == ti).collect(); - assert_eq!(t.samples.len(), pk.len(), "{name}: index size track {ti}"); - for (i, p) in pk.iter().enumerate() { - assert_eq!(t.samples[i].pts, p.pts); - assert_eq!(t.samples[i].keyframe, p.keyframe); - assert_eq!(t.sample_size(i), Some(p.data.len() as u64)); - } - if let Some(i) = pk.len().checked_sub(1) { - assert_eq!(file.read_sample(&bytes[..], ti, i).unwrap(), pk[i].data); - } - } - // duration - if let Some(fd) = json["format"]["duration"].as_str().and_then(|s| s.parse::().ok()) - && let Some(ours) = file.duration_ns() + eprintln!("{name}: {} packets, {pts_checked} pts and {duration_checked} durations compared", packets.len()); +} + +fn compare_packet_pts(ffprobe_packet: &Value, packet: &Packet, context: &str, checked: &mut usize) { + if let Some(pts) = int(&ffprobe_packet["pts"]) + && !(packet.lace > 0 && packet.duration == 0) { - assert!((fd - ours as f64 / 1e9).abs() < 0.05, "{name}: duration {fd} vs {ours}"); + // FFmpeg truncates per-lace duration to whole ticks; later laces can differ by one tick each. + assert!((pts - packet.pts).abs() <= packet.lace as i64, "{context}: pts ffprobe {pts} vs {}", packet.pts); + *checked += 1; } +} + +fn compare_packet_duration( + ffprobe_packet: &Value, + packet: &Packet, + packets: &[Packet], + file: &MkvFile, + context: &str, + index: usize, + checked: &mut usize, +) { + // The final audio/video frame cut by `-t` has a shortened BlockDuration, but ffprobe reports + // the codec frame duration. Subtitle durations remain exact cue intervals and are compared. + let last_of_track = packets[index + 1..].iter().all(|next| next.track != packet.track); + if let Some(duration) = int(&ffprobe_packet["duration"]) + && packet.duration != 0 + && (!last_of_track || file.tracks[packet.track].kind == TrackKind::Subtitle) + { + assert!((duration - packet.duration as i64).abs() <= 1, "{context}: duration ffprobe {duration} vs {}", packet.duration); + *checked += 1; + } +} + +fn assert_packet_index_matches(file: &MkvFile, packets: &[Packet], bytes: &[u8], name: &str) { + for (track_index, track) in file.tracks.iter().enumerate() { + let track_packets: Vec<&Packet> = packets.iter().filter(|packet| packet.track == track_index).collect(); + assert_eq!(track.samples.len(), track_packets.len(), "{name}: index size track {track_index}"); + for (sample_index, packet) in track_packets.iter().enumerate() { + assert_eq!(track.samples[sample_index].pts, packet.pts); + assert_eq!(track.samples[sample_index].keyframe, packet.keyframe); + assert_eq!(track.sample_size(sample_index), Some(packet.data.len() as u64)); + } + if let Some(sample_index) = track_packets.len().checked_sub(1) { + assert_eq!(file.read_sample(bytes, track_index, sample_index).unwrap(), track_packets[sample_index].data); + } + } +} + +fn assert_duration_matches(json: &Value, file: &MkvFile, name: &str) { + if let Some(ffprobe_duration) = json["format"]["duration"].as_str().and_then(|duration| duration.parse::().ok()) + && let Some(duration_ns) = file.duration_ns() + { + assert!((ffprobe_duration - duration_ns as f64 / 1e9).abs() < 0.05, "{name}: duration {ffprobe_duration} vs {duration_ns}"); + } +} + +/// Compare every packet (file order) and stream parameter against ffprobe. +fn compare(ffprobe: &Path, path: &Path) -> (MkvFile, Vec) { + let json = ffprobe_json(ffprobe, path); + let bytes = std::fs::read(path).unwrap(); + let mut demuxer = Demuxer::from_slice(&bytes).unwrap(); + let name = path.file_name().unwrap().to_string_lossy().to_string(); + let file = demuxer.file().clone(); + assert_streams_match(&json, &file, &name); + let packets: Vec = demuxer.by_ref().collect::>().unwrap(); + assert_packets_match(&json, &file, &packets, &name); + assert_packet_index_matches(&file, &packets, &bytes, &name); + assert_duration_matches(&json, &file, &name); (file, packets) } diff --git a/crates/text/src/linebreak/knuth_plass.rs b/crates/text/src/linebreak/knuth_plass.rs index 71f63c2d..742c3245 100644 --- a/crates/text/src/linebreak/knuth_plass.rs +++ b/crates/text/src/linebreak/knuth_plass.rs @@ -98,43 +98,11 @@ pub fn knuth_plass(items: &[Item], width: &dyn Fn(usize) -> f64, p: &Params) -> continue; } } - let weight = if f.ratio > 0.0 { - p.stretch_weight - } else if f.ratio < 0.0 { - p.shrink_weight - } else { - 1.0 - }; - let badness = (weight * 100.0 * f.ratio.abs().min(MAX_RATIO).powi(3)).min(p.max_badness); - let lp = p.line_penalty + badness; - let mut d = if forced { - lp * lp - } else if cost >= 0.0 { - lp * lp + cost * cost - } else { - lp * lp - cost * cost - }; - if flagged && n.flagged { - d += p.flagged_demerits; - } - // A ragged line ending at a forced break says nothing about the rag: it keeps its - // neighbour's class. - let class = fitness_class(f.ratio, &p.fitness); - let fitness = if forced && p.rag.is_some() { n.fitness.unwrap_or(class) } else { class }; - if n.fitness.is_some_and(|nf| fitness.abs_diff(nf) > 1) { - d += p.fitness_demerits; - } + let (d, fitness) = line_demerits(&n, f.ratio, forced, flagged, cost, p); let hyphens = if flagged { (n.hyphens + 1).min(max_hyphens) } else { 0 }; let line = (n.line + 1).min(p.uniform_from); let demerits = n.demerits + d; - match best.iter_mut().find(|c| c.line == line && c.fitness == fitness && c.hyphens == hyphens) { - Some(c) if c.demerits <= demerits => {} - Some(c) => { - c.demerits = demerits; - c.from = a; - } - None => best.push(Candidate { line, fitness, hyphens, demerits, from: a }), - } + record(&mut best, Candidate { line, fitness, hyphens, demerits, from: a }); } if p.overfull && keep.is_empty() @@ -197,6 +165,62 @@ pub fn knuth_plass(items: &[Item], width: &dyn Fn(usize) -> f64, p: &Params) -> } } let end = if s.ends_forced() { m - 1 } else { m }; + let chain = best_chain(&nodes, &active, end)?; + let mut start = 0; + let mut lines = Vec::with_capacity(chain.len()); + for (k, &b) in chain.iter().enumerate() { + let next = s.next_start(b); + lines.push(s.line(start, b, next, width(k), p, 0.0)); + start = next; + } + Some(lines) +} + +/// Keep the cheaper of the candidates sharing a (line class, fitness, hyphen state). +fn record(best: &mut Vec, new: Candidate) { + match best.iter_mut().find(|c| c.line == new.line && c.fitness == new.fitness && c.hyphens == new.hyphens) { + Some(c) if c.demerits <= new.demerits => {} + Some(c) => { + c.demerits = new.demerits; + c.from = new.from; + } + None => best.push(new), + } +} + +/// Demerits of ending a line of adjustment `ratio` after node `n` at a break, and its fitness class. +fn line_demerits(n: &Node, ratio: f64, forced: bool, flagged: bool, cost: f64, p: &Params) -> (f64, u8) { + let weight = if ratio > 0.0 { + p.stretch_weight + } else if ratio < 0.0 { + p.shrink_weight + } else { + 1.0 + }; + let badness = (weight * 100.0 * ratio.abs().min(MAX_RATIO).powi(3)).min(p.max_badness); + let lp = p.line_penalty + badness; + let mut d = if forced { + lp * lp + } else if cost >= 0.0 { + lp * lp + cost * cost + } else { + lp * lp - cost * cost + }; + if flagged && n.flagged { + d += p.flagged_demerits; + } + // A ragged line ending at a forced break says nothing about the rag: it keeps its + // neighbour's class. + let class = fitness_class(ratio, &p.fitness); + let fitness = if forced && p.rag.is_some() { n.fitness.unwrap_or(class) } else { class }; + if n.fitness.is_some_and(|nf| fitness.abs_diff(nf) > 1) { + d += p.fitness_demerits; + } + (d, fitness) +} + +/// Break positions of the cheapest path that ends at `end`, in order. +fn best_chain(nodes: &[Node], active: &[usize], end: usize) -> Option> { let last = active .iter() .copied() @@ -212,12 +236,5 @@ pub fn knuth_plass(items: &[Item], width: &dyn Fn(usize) -> f64, p: &Params) -> cur = n.prev; } chain.reverse(); - let mut start = 0; - let mut lines = Vec::with_capacity(chain.len()); - for (k, &b) in chain.iter().enumerate() { - let next = s.next_start(b); - lines.push(s.line(start, b, next, width(k), p, 0.0)); - start = next; - } - Some(lines) + Some(chain) } diff --git a/crates/ui/src/docking/command_tests.rs b/crates/ui/src/docking/command_tests.rs index c6ac3f1d..fd693ac4 100644 --- a/crates/ui/src/docking/command_tests.rs +++ b/crates/ui/src/docking/command_tests.rs @@ -69,13 +69,18 @@ fn tab(before: Option<&str>) -> Placement

{ Placement::Tab { before: before.map(s) } } -#[test] -fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() { - let mut seen = BTreeSet::new(); - let mut id = |params: Value| { - seen.insert(params["command"].as_str().unwrap().to_owned()); - params - }; +fn record_id(seen: &mut BTreeSet, params: Value) -> Value { + seen.insert(params["command"].as_str().unwrap().to_owned()); + params +} + +fn ui_dock_operation(mut params: Value) -> Value { + params["command"] = json!("ui.dock"); + params +} + +fn exercise_move_commands(seen: &mut BTreeSet) { + let mut id = |params: Value| record_id(seen, params); // The raw action form, in both protocols. let raw = json!({"MoveFloating": {"panel": "color", "rect": [44, 66, 360, 450]}}); @@ -105,6 +110,10 @@ fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() { to: Target::At { anchor: s("layers"), placement: Placement::Group { below: false, height: None } } }] ); +} + +fn exercise_float_and_dock_commands(seen: &mut BTreeSet) { + let mut id = |params: Value| record_id(seen, params); // float: a docked panel, a lone floating panel, a group and a column. assert_eq!( @@ -149,6 +158,10 @@ fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() { assert_eq!(actions(json!({"command": "window.panel.dock", "panel": "layers"})), vec![]); let (shown, _) = applied(json!({"command": "window.panel.dock", "panel": "swatches"})); assert!(shown.layout.contains(&s("swatches")) && shown.hidden.is_empty()); +} + +fn exercise_visibility_and_state_commands(seen: &mut BTreeSet) { + let mut id = |params: Value| record_id(seen, params); // activate shows a hidden panel; close actions report what they closed. let (shown, _) = applied(id(json!({"command": "window.panel.activate", "panel": "swatches"}))); @@ -171,6 +184,10 @@ fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() { actions(id(json!({"command": "window.panel.iconic", "panel": "history"}))), vec![Action::SetIconic { panel: s("history"), iconic: true }] ); +} + +fn exercise_window_and_workspace_commands(seen: &mut BTreeSet) { + let mut id = |params: Value| record_id(seen, params); // window: a floating window goes native at its rect; a docked group floats first. assert_eq!( @@ -204,13 +221,12 @@ fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() { // Lock toggles or sets. assert!(applied(id(json!({"command": "window.workspace.lock"}))).0.locked); assert!(!applied(json!({"command": "window.workspace.lock", "locked": false})).0.locked); +} + +fn exercise_ui_dock_commands() { + let op = ui_dock_operation; // The small apps' ui.dock operations. - let op = |params: Value| { - let mut params = params; - params["command"] = json!("ui.dock"); - params - }; let (shown, _) = applied(op(json!({"operation": "open", "panel": "swatches"}))); assert!(shown.layout.contains(&s("swatches"))); assert_eq!(actions(op(json!({"operation": "close", "panel": "layers"}))), vec![Action::Close { panel: s("layers") }]); @@ -250,9 +266,10 @@ fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() { actions(op(json!({"operation": "resizeSplit", "path": [], "size": {"Ratio": 0.35}}))), vec![Action::ResizeSplit { path: vec![], size: SplitSize::Ratio(0.35) }] ); +} - let ids: BTreeSet = DockCommand::

::IDS.iter().map(|id| (*id).to_owned()).collect(); - assert_eq!(seen, ids, "one case per id"); +fn reject_invalid_commands() { + let op = ui_dock_operation; // Bad params: parse errors, or a DockError with nothing changed. for params in [ @@ -282,6 +299,9 @@ fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() { DockError::InvalidPlacement, ); rejected(op(json!({"operation": "resizeSplit", "path": [true, true, true], "size": {"Ratio": 0.5}})), DockError::MissingTarget); +} + +fn reject_locked_native_window() { // A docked group cannot float into a native window when locked. let mut locked = workspace(); locked.locked = true; @@ -290,6 +310,20 @@ fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() { assert_eq!(locked, before); } +#[test] +fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() { + let mut seen = BTreeSet::new(); + exercise_move_commands(&mut seen); + exercise_float_and_dock_commands(&mut seen); + exercise_visibility_and_state_commands(&mut seen); + exercise_window_and_workspace_commands(&mut seen); + exercise_ui_dock_commands(); + let ids: BTreeSet = DockCommand::

::IDS.iter().map(|id| (*id).to_owned()).collect(); + assert_eq!(seen, ids, "one case per id"); + reject_invalid_commands(); + reject_locked_native_window(); +} + #[test] fn side_zones_map_to_the_placement_the_anchor_container_takes_in_either_direction() { let command = diff --git a/crates/ui/src/text.rs b/crates/ui/src/text.rs index ec4d7826..b3f9e48b 100644 --- a/crates/ui/src/text.rs +++ b/crates/ui/src/text.rs @@ -158,6 +158,109 @@ mod tests { } } + fn fractional_ink_fixture() -> (Arc, f32, Rect, Galley) { + let shift = f32::from_bits(0x3f2a_aaab); + let mut original = layout_text(" \nI", 1.5, Align::Min); + let fixture = Arc::make_mut(&mut original); + assert_eq!(fixture.rows.len(), 2); + fixture.rect = Rect::from_min_size(Pos2::ZERO, vec2(1.0, 64.0)); + fixture.rows[0].pos = Pos2::ZERO; + let empty = Arc::make_mut(&mut fixture.rows[0].row); + empty.size.x = 0.5; // Max at width 0 snaps to -one pixel, not -two pixels. + empty.visuals = Default::default(); + fixture.rows[1].pos = pos2(0.0, shift); + let ink = Arc::make_mut(&mut fixture.rows[1].row); + ink.size.x = 1.0; + let mut mesh = egui::epaint::Mesh::default(); + mesh.add_rect_with_uv( + Rect::from_min_max(pos2(-2.0, -2.0), pos2(f32::from_bits(0x4055_5555), shift)), + Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), + Color32::BLACK, + ); + ink.visuals = egui::epaint::text::RowVisuals { + mesh_bounds: mesh.calc_bounds(), + glyph_index_start: 0, + glyph_vertex_range: 0..mesh.vertices.len(), + mesh, + }; + let local = ink.visuals.mesh_bounds; + assert!(ink.visuals.mesh.is_valid()); + assert_eq!(ink.visuals.mesh.vertices.len(), 4); + assert_eq!(ink.visuals.mesh.indices.len(), 6); + for vertex in &ink.visuals.mesh.vertices { + assert!(local.contains(vertex.pos)); + } + fixture.mesh_bounds = Rect::from_min_max(local.min + vec2(0.0, shift), local.max + vec2(0.0, shift)); + fixture.num_vertices = 4; + fixture.num_indices = 6; + let snapshot = (*original).clone(); + assert!(snapshot.rows[0].visuals.mesh.vertices.is_empty()); + assert!(snapshot.rows[0].visuals.mesh.indices.is_empty()); + assert_eq!(snapshot.rows[0].visuals.mesh_bounds, Rect::NOTHING); + assert_eq!(snapshot.rows.iter().filter(|row| !row.visuals.mesh.vertices.is_empty()).count(), 1); + (original, shift, local, snapshot) + } + + fn align_fixture(galley: Arc) -> AlignedGalley { + let mut index = 0; + let aligned = align_galley_rows(galley, 0.0, |_| { + index += 1; + if index == 1 { Align::Max } else { Align::Min } + }); + assert_eq!(index, 2); + aligned + } + + fn assert_shared_rows_and_bounds(aligned: &Galley, snapshot: &Galley, offset: Vec2, shift: f32) { + assert_eq!(aligned.num_vertices, snapshot.num_vertices); + assert_eq!(aligned.num_indices, snapshot.num_indices); + for (index, (row, old)) in aligned.rows.iter().zip(&snapshot.rows).enumerate() { + assert!(Arc::ptr_eq(&row.row, &old.row), "cached glyphs and meshes stay shared"); + assert_eq!(row.pos.y, old.pos.y); + let reading_position = if index == 0 { pos2(-shift, 0.0) } else { old.pos }; + assert_eq!(row.pos + offset, reading_position); + assert!(aligned.rect.contains_rect(row.rect())); + } + assert_eq!(aligned.rows[0].visuals.mesh_bounds, Rect::NOTHING); + } + + fn assert_translated_ink_is_contained(aligned: &Galley, local: Rect) { + let row = &aligned.rows[1]; + let legacy = local.translate(row.pos.to_vec2()); + let endpoints = Rect::from_min_max(local.min + row.pos.to_vec2(), local.max + row.pos.to_vec2()); + assert_eq!(legacy.max.x.to_bits(), 0x407f_ffff, "the legacy X maximum rounds inward"); + assert_eq!(endpoints.max.x.to_bits(), 0x4080_0000); + assert_eq!(legacy.max.y.to_bits(), 0x3faa_aaac, "the legacy Y maximum rounds outward"); + assert_eq!(endpoints.max.y.to_bits(), 0x3faa_aaab); + assert!(endpoints.max.x > legacy.max.x && endpoints.max.y < legacy.max.y); + assert!(aligned.mesh_bounds.is_finite()); + assert!(aligned.mesh_bounds.contains_rect(legacy), "retain the complete public translated row rectangle"); + for (index, vertex) in row.visuals.mesh.vertices.iter().enumerate() { + let position = row.pos + vertex.pos.to_vec2(); + assert!( + aligned.mesh_bounds.contains(position), + "actual translated vertex {index} must be contained: position={:?} bits={:x?}, bounds_max={:?} bits={:x?}", + [position.x, position.y], + [position.x.to_bits(), position.y.to_bits()], + [aligned.mesh_bounds.max.x, aligned.mesh_bounds.max.y], + [aligned.mesh_bounds.max.x.to_bits(), aligned.mesh_bounds.max.y.to_bits()] + ); + } + assert_eq!(aligned.mesh_bounds, legacy.union(endpoints)); + } + + fn assert_empty_ink_stays_empty(original: &Galley) { + let mut all_empty = original.clone(); + for row in &mut all_empty.rows { + Arc::make_mut(&mut row.row).visuals = Default::default(); + } + all_empty.mesh_bounds = Rect::NOTHING; + all_empty.num_vertices = 0; + all_empty.num_indices = 0; + let all_empty = align_fixture(Arc::new(all_empty)); + assert_eq!(parts(&all_empty).0.mesh_bounds, Rect::NOTHING); + } + /// Compare two rects componentwise within `epsilon`. fn close_rect(actual: Rect, expected: Rect, epsilon: f32) { assert!((actual.min - expected.min).length() <= epsilon, "{actual:?} != {expected:?}"); @@ -377,57 +480,7 @@ mod tests { /// and the complete legacy rect, equals the union of both rounding directions, survives repetition, /// and remains `Rect::NOTHING` when all ink is empty. fn fractional_ink_bounds_contain_translated_rectangles_and_vertices() { - // A synthetic cached quad isolates the two directions of binary32 rounding. The - // whitespace row supplies the normalization offset without contributing any ink. - let shift = f32::from_bits(0x3f2a_aaab); - let mut original = layout_text(" \nI", 1.5, Align::Min); - let fixture = Arc::make_mut(&mut original); - assert_eq!(fixture.rows.len(), 2); - fixture.rect = Rect::from_min_size(Pos2::ZERO, vec2(1.0, 64.0)); - fixture.rows[0].pos = Pos2::ZERO; - let empty = Arc::make_mut(&mut fixture.rows[0].row); - empty.size.x = 0.5; // Max at width 0 snaps to -one pixel, not -two pixels. - empty.visuals = Default::default(); - fixture.rows[1].pos = pos2(0.0, shift); - let ink = Arc::make_mut(&mut fixture.rows[1].row); - ink.size.x = 1.0; - let mut mesh = egui::epaint::Mesh::default(); - mesh.add_rect_with_uv( - Rect::from_min_max(pos2(-2.0, -2.0), pos2(f32::from_bits(0x4055_5555), shift)), - Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), - Color32::BLACK, - ); - ink.visuals = egui::epaint::text::RowVisuals { - mesh_bounds: mesh.calc_bounds(), - glyph_index_start: 0, - glyph_vertex_range: 0..mesh.vertices.len(), - mesh, - }; - let local = ink.visuals.mesh_bounds; - assert!(ink.visuals.mesh.is_valid()); - assert_eq!(ink.visuals.mesh.vertices.len(), 4); - assert_eq!(ink.visuals.mesh.indices.len(), 6); - for vertex in &ink.visuals.mesh.vertices { - assert!(local.contains(vertex.pos)); - } - fixture.mesh_bounds = Rect::from_min_max(local.min + vec2(0.0, shift), local.max + vec2(0.0, shift)); - fixture.num_vertices = 4; - fixture.num_indices = 6; - let snapshot = (*original).clone(); - assert!(snapshot.rows[0].visuals.mesh.vertices.is_empty()); - assert!(snapshot.rows[0].visuals.mesh.indices.is_empty()); - assert_eq!(snapshot.rows[0].visuals.mesh_bounds, Rect::NOTHING); - assert_eq!(snapshot.rows.iter().filter(|row| !row.visuals.mesh.vertices.is_empty()).count(), 1); - - let align_fixture = |galley| { - let mut index = 0; - let aligned = align_galley_rows(galley, 0.0, |_| { - index += 1; - if index == 1 { Align::Max } else { Align::Min } - }); - assert_eq!(index, 2); - aligned - }; + let (original, shift, local, snapshot) = fractional_ink_fixture(); let result = align_fixture(original.clone()); let (aligned, offset, reading) = parts(&result); assert_eq!(offset, vec2(-shift, 0.0)); @@ -438,54 +491,15 @@ mod tests { assert_eq!(reading, Rect::from_min_size(Pos2::ZERO, vec2(0.0, 64.0))); assert!(aligned.rect.contains_rect(reading.translate(-offset))); assert_eq!(*original, snapshot, "alignment must leave the complete source unchanged"); - assert_eq!(aligned.num_vertices, snapshot.num_vertices); - assert_eq!(aligned.num_indices, snapshot.num_indices); - for (index, (row, old)) in aligned.rows.iter().zip(&snapshot.rows).enumerate() { - assert!(Arc::ptr_eq(&row.row, &old.row), "cached glyphs and meshes stay shared"); - assert_eq!(row.pos.y, old.pos.y); - let reading_position = if index == 0 { pos2(-shift, 0.0) } else { old.pos }; - assert_eq!(row.pos + offset, reading_position); - assert!(aligned.rect.contains_rect(row.rect())); - } - assert_eq!(aligned.rows[0].visuals.mesh_bounds, Rect::NOTHING); + assert_shared_rows_and_bounds(aligned, &snapshot, offset, shift); let repeated = align_fixture(aligned.clone()); let (again, again_offset, again_reading) = parts(&repeated); assert_eq!(again_offset, offset); assert_eq!(again_reading, reading); assert_eq!(**again, **aligned, "repeated alignment is exact, including cached ink bounds"); - let mut all_empty = (*original).clone(); - for row in &mut all_empty.rows { - Arc::make_mut(&mut row.row).visuals = Default::default(); - } - all_empty.mesh_bounds = Rect::NOTHING; - all_empty.num_vertices = 0; - all_empty.num_indices = 0; - let all_empty = align_fixture(Arc::new(all_empty)); - assert_eq!(parts(&all_empty).0.mesh_bounds, Rect::NOTHING); - - let row = &aligned.rows[1]; - let legacy = local.translate(row.pos.to_vec2()); - let endpoints = Rect::from_min_max(local.min + row.pos.to_vec2(), local.max + row.pos.to_vec2()); - assert_eq!(legacy.max.x.to_bits(), 0x407f_ffff, "the legacy X maximum rounds inward"); - assert_eq!(endpoints.max.x.to_bits(), 0x4080_0000); - assert_eq!(legacy.max.y.to_bits(), 0x3faa_aaac, "the legacy Y maximum rounds outward"); - assert_eq!(endpoints.max.y.to_bits(), 0x3faa_aaab); - assert!(endpoints.max.x > legacy.max.x && endpoints.max.y < legacy.max.y); - assert!(aligned.mesh_bounds.is_finite()); - assert!(aligned.mesh_bounds.contains_rect(legacy), "retain the complete public translated row rectangle"); - for (index, vertex) in row.visuals.mesh.vertices.iter().enumerate() { - let position = row.pos + vertex.pos.to_vec2(); - assert!( - aligned.mesh_bounds.contains(position), - "actual translated vertex {index} must be contained: position={:?} bits={:x?}, bounds_max={:?} bits={:x?}", - [position.x, position.y], - [position.x.to_bits(), position.y.to_bits()], - [aligned.mesh_bounds.max.x, aligned.mesh_bounds.max.y], - [aligned.mesh_bounds.max.x.to_bits(), aligned.mesh_bounds.max.y.to_bits()] - ); - } - assert_eq!(aligned.mesh_bounds, legacy.union(endpoints)); + assert_empty_ink_stays_empty(&original); + assert_translated_ink_is_contained(aligned, local); } #[test]