Index measured label field overlap lookups with exact differential oracles
This commit is contained in:
@@ -26,7 +26,11 @@ use iced::advanced::{
|
|||||||
text::{Alignment, LineHeight, Shaping},
|
text::{Alignment, LineHeight, Shaping},
|
||||||
};
|
};
|
||||||
use iced::{Color, Font, Pixels, Point, Rectangle, Size, widget::canvas::Text};
|
use iced::{Color, Font, Pixels, Point, Rectangle, Size, widget::canvas::Text};
|
||||||
use std::{collections::VecDeque, ops::Range, sync::Arc};
|
use std::{
|
||||||
|
collections::{HashSet, VecDeque},
|
||||||
|
ops::Range,
|
||||||
|
sync::Arc,
|
||||||
|
};
|
||||||
use unicode_segmentation::UnicodeSegmentation;
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
|
|
||||||
const MAX_SOURCE_BYTES: usize = 64 * 1024;
|
const MAX_SOURCE_BYTES: usize = 64 * 1024;
|
||||||
@@ -102,6 +106,7 @@ pub(super) struct Label {
|
|||||||
clusters: Vec<FieldCluster>,
|
clusters: Vec<FieldCluster>,
|
||||||
source_spans: Vec<SourceSpan>,
|
source_spans: Vec<SourceSpan>,
|
||||||
retained_normalized: usize,
|
retained_normalized: usize,
|
||||||
|
field_index: FieldIndex,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
@@ -142,6 +147,69 @@ struct FieldCluster {
|
|||||||
missing: bool,
|
missing: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Immutable overlap membership, built from Normalized's ordered, disjoint
|
||||||
|
/// scalar/separator spans. Clusters may overlap and need not be in visual order.
|
||||||
|
/// Construction costs O(C log S + I), storage O(S + C + I), where I is the
|
||||||
|
/// number of actual span/cluster overlaps, rather than the full S*C product.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct FieldIndex {
|
||||||
|
clusters_by_span: Vec<Vec<usize>>,
|
||||||
|
source_extents: Vec<Option<SourceExtents>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
struct SourceExtents {
|
||||||
|
minimum_end: usize,
|
||||||
|
maximum_start: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FieldIndex {
|
||||||
|
fn new(spans: &[SourceSpan], clusters: &[FieldCluster]) -> Self {
|
||||||
|
debug_assert!(
|
||||||
|
spans
|
||||||
|
.iter()
|
||||||
|
.all(|span| span.normalized.start < span.normalized.end)
|
||||||
|
);
|
||||||
|
debug_assert!(
|
||||||
|
spans
|
||||||
|
.windows(2)
|
||||||
|
.all(|pair| pair[0].normalized.end <= pair[1].normalized.start)
|
||||||
|
);
|
||||||
|
let mut result = Self {
|
||||||
|
clusters_by_span: vec![vec![]; spans.len()],
|
||||||
|
source_extents: vec![None; clusters.len()],
|
||||||
|
};
|
||||||
|
for (index, cluster) in clusters.iter().enumerate() {
|
||||||
|
if cluster.normalized.start >= cluster.normalized.end {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let first =
|
||||||
|
spans.partition_point(|span| span.normalized.end <= cluster.normalized.start);
|
||||||
|
for (offset, span) in spans[first..].iter().enumerate() {
|
||||||
|
if span.normalized.start >= cluster.normalized.end {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
result.clusters_by_span[first + offset].push(index);
|
||||||
|
let extents = result.source_extents[index].get_or_insert(SourceExtents {
|
||||||
|
minimum_end: span.source.end,
|
||||||
|
maximum_start: span.source.start,
|
||||||
|
});
|
||||||
|
extents.minimum_end = extents.minimum_end.min(span.source.end);
|
||||||
|
extents.maximum_start = extents.maximum_start.max(span.source.start);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translation rejects nonfinite rectangle components before deduplication.
|
||||||
|
// For finite f32 values, bit equality differs from `==` only at signed zero.
|
||||||
|
// Normalize only the key: preserve the first rectangle and its original bits.
|
||||||
|
fn rectangle_key(bounds: Rectangle) -> [u32; 4] {
|
||||||
|
[bounds.x, bounds.y, bounds.width, bounds.height]
|
||||||
|
.map(|value| if value == 0. { 0 } else { value.to_bits() })
|
||||||
|
}
|
||||||
|
|
||||||
impl Label {
|
impl Label {
|
||||||
/// Geometry and coverage are separate: truncation is not missing-font
|
/// Geometry and coverage are separate: truncation is not missing-font
|
||||||
/// coverage, and a positive .notdef advance is not represented field text.
|
/// coverage, and a positive .notdef advance is not represented field text.
|
||||||
@@ -168,7 +236,7 @@ impl Label {
|
|||||||
shared_cluster: false,
|
shared_cluster: false,
|
||||||
};
|
};
|
||||||
let mut selected = vec![false; self.clusters.len()];
|
let mut selected = vec![false; self.clusters.len()];
|
||||||
for span in &self.source_spans {
|
for (span_index, span) in self.source_spans.iter().enumerate() {
|
||||||
if span.whitespace || !overlaps(&span.source, &field) {
|
if span.whitespace || !overlaps(&span.source, &field) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -178,27 +246,25 @@ impl Label {
|
|||||||
append_range(&mut result.truncated, span.source.clone());
|
append_range(&mut result.truncated, span.source.clone());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let matching: Vec<_> = self
|
let matching = &self.field_index.clusters_by_span[span_index];
|
||||||
.clusters
|
result.shared_cluster |= matching.iter().any(|&index| {
|
||||||
.iter()
|
let cluster = &self.clusters[index];
|
||||||
.enumerate()
|
|
||||||
.filter(|(_, cluster)| overlaps(&cluster.normalized, &span.normalized))
|
|
||||||
.collect();
|
|
||||||
result.shared_cluster |= matching.iter().any(|(_, cluster)| {
|
|
||||||
cluster.normalized.end > self.retained_normalized
|
cluster.normalized.end > self.retained_normalized
|
||||||
|| self.source_spans.iter().any(|other| {
|
|| self.field_index.source_extents[index].is_some_and(|extents| {
|
||||||
overlaps(&other.normalized, &cluster.normalized)
|
// Any overlapping source span outside the requested
|
||||||
&& !overlaps(&other.source, &field)
|
// field is exactly either a too-small end or too-large
|
||||||
|
// start. Include whitespace/nonpainting spans, as before.
|
||||||
|
extents.minimum_end <= field.start || extents.maximum_start >= field.end
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
let issue = if span.non_painting {
|
let issue = if span.non_painting {
|
||||||
Some(FieldIssue::NonPaintingScalar)
|
Some(FieldIssue::NonPaintingScalar)
|
||||||
} else if matching.iter().any(|(_, cluster)| cluster.missing) {
|
} else if matching.iter().any(|&index| self.clusters[index].missing) {
|
||||||
Some(FieldIssue::MissingGlyph)
|
Some(FieldIssue::MissingGlyph)
|
||||||
} else if matching.is_empty()
|
} else if matching.is_empty()
|
||||||
|| matching
|
|| matching
|
||||||
.iter()
|
.iter()
|
||||||
.all(|(_, cluster)| cluster.rectangles.is_empty())
|
.all(|&index| self.clusters[index].rectangles.is_empty())
|
||||||
{
|
{
|
||||||
Some(FieldIssue::NoAdvanceGeometry)
|
Some(FieldIssue::NoAdvanceGeometry)
|
||||||
} else {
|
} else {
|
||||||
@@ -212,10 +278,11 @@ impl Label {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
append_range(&mut result.covered, span.source.clone());
|
append_range(&mut result.covered, span.source.clone());
|
||||||
for (index, _) in matching {
|
for &index in matching {
|
||||||
selected[index] = true;
|
selected[index] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let mut seen = HashSet::new();
|
||||||
for (cluster, selected) in self.clusters.iter().zip(selected) {
|
for (cluster, selected) in self.clusters.iter().zip(selected) {
|
||||||
if !selected {
|
if !selected {
|
||||||
continue;
|
continue;
|
||||||
@@ -225,7 +292,7 @@ impl Label {
|
|||||||
Point::new(position.x + bounds.x, position.y + bounds.y),
|
Point::new(position.x + bounds.x, position.y + bounds.y),
|
||||||
bounds.size(),
|
bounds.size(),
|
||||||
)?;
|
)?;
|
||||||
if !result.rectangles.contains(&translated) {
|
if seen.insert(rectangle_key(translated)) {
|
||||||
result.rectangles.push(translated);
|
result.rectangles.push(translated);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -502,6 +569,7 @@ impl Cache {
|
|||||||
);
|
);
|
||||||
field_clusters(entry, zoom)?
|
field_clusters(entry, zoom)?
|
||||||
};
|
};
|
||||||
|
let field_index = FieldIndex::new(&normalized.spans, &clusters);
|
||||||
let label = Arc::new(Label {
|
let label = Arc::new(Label {
|
||||||
source: source.into(),
|
source: source.into(),
|
||||||
displayed,
|
displayed,
|
||||||
@@ -513,6 +581,7 @@ impl Cache {
|
|||||||
clusters,
|
clusters,
|
||||||
source_spans: normalized.spans,
|
source_spans: normalized.spans,
|
||||||
retained_normalized: retained,
|
retained_normalized: retained,
|
||||||
|
field_index,
|
||||||
});
|
});
|
||||||
if self.entries.len() == MAX_ENTRIES {
|
if self.entries.len() == MAX_ENTRIES {
|
||||||
self.entries.pop_front();
|
self.entries.pop_front();
|
||||||
@@ -684,3 +753,7 @@ mod tests;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "scenario_label/field_tests.rs"]
|
#[path = "scenario_label/field_tests.rs"]
|
||||||
mod field_tests;
|
mod field_tests;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "scenario_label/lookup_tests.rs"]
|
||||||
|
mod lookup_tests;
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ fn assert_ligature_fixture_face(oracle: &Paragraph) {
|
|||||||
let mut count = 0;
|
let mut count = 0;
|
||||||
for glyph in oracle.buffer().layout_runs().flat_map(|run| run.glyphs) {
|
for glyph in oracle.buffer().layout_runs().flat_map(|run| run.glyphs) {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fonts.raw().db().with_face_data(glyph.font_id, |bytes, index| {
|
fonts
|
||||||
|
.raw()
|
||||||
|
.db()
|
||||||
|
.with_face_data(glyph.font_id, |bytes, index| {
|
||||||
index == 0 && bytes == LIGATURE_FONT
|
index == 0 && bytes == LIGATURE_FONT
|
||||||
}),
|
}),
|
||||||
Some(true),
|
Some(true),
|
||||||
@@ -568,6 +571,13 @@ fn field_lookup_4096_scalars_and_combining_heavy_full_coverage() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let resolve_ns = start.elapsed().as_nanos();
|
let resolve_ns = start.elapsed().as_nanos();
|
||||||
assert_eq!(label.displayed, source);
|
assert_eq!(label.displayed, source);
|
||||||
|
// The preserved 4c9 lookup runs on the exact same immutable shaped
|
||||||
|
// label. Its timing is separate from the optimized samples below.
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let original = label
|
||||||
|
.original_field_layout(&session, 1., Point::ORIGIN, 0..source.len())
|
||||||
|
.unwrap();
|
||||||
|
let original_lookup_ns = start.elapsed().as_nanos();
|
||||||
let mut lookup_ns = vec![];
|
let mut lookup_ns = vec![];
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
@@ -579,11 +589,33 @@ fn field_lookup_4096_scalars_and_combining_heavy_full_coverage() {
|
|||||||
assert!(field.truncated.is_empty() && field.unresolved.is_empty());
|
assert!(field.truncated.is_empty() && field.unresolved.is_empty());
|
||||||
assert!(!field.partially_retained && !field.shared_cluster);
|
assert!(!field.partially_retained && !field.shared_cluster);
|
||||||
assert_complete_boxes(&field.rectangles, expected.clone());
|
assert_complete_boxes(&field.rectangles, expected.clone());
|
||||||
|
super::lookup_tests::assert_exact(&field, &original);
|
||||||
|
}
|
||||||
|
// Fresh label construction plus lookup, not just a retained-index
|
||||||
|
// measurement: moving the old quadratic work into resolve cannot hide.
|
||||||
|
let mut fresh_resolve_lookup_ns = vec![];
|
||||||
|
for _ in 0..3 {
|
||||||
|
let mut fresh_cache = Cache::default();
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let fresh = fresh_cache
|
||||||
|
.resolve(&session, &source, 1_000_000., 1., style)
|
||||||
|
.unwrap();
|
||||||
|
let field = fresh
|
||||||
|
.field_layout(&session, 1., Point::ORIGIN, 0..source.len())
|
||||||
|
.unwrap();
|
||||||
|
fresh_resolve_lookup_ns.push(start.elapsed().as_nanos());
|
||||||
|
super::lookup_tests::assert_exact(&field, &original);
|
||||||
|
assert_complete_boxes(&field.rectangles, expected.clone());
|
||||||
}
|
}
|
||||||
records.push(serde_json::json!({
|
records.push(serde_json::json!({
|
||||||
"case": name, "scalars": 4096, "graphemes": expected_graphemes,
|
"case": name, "scalars": 4096, "graphemes": expected_graphemes,
|
||||||
"source_bytes": source.len(), "expected_rectangles": expected.len(),
|
"source_bytes": source.len(), "expected_rectangles": expected.len(),
|
||||||
"resolved_fonts": identities, "resolve_ns": resolve_ns, "lookup_ns": lookup_ns,
|
"resolved_fonts": identities, "resolve_ns": resolve_ns, "lookup_ns": lookup_ns,
|
||||||
|
"original_lookup_ns": original_lookup_ns,
|
||||||
|
"fresh_resolve_lookup_ns": fresh_resolve_lookup_ns,
|
||||||
|
"source_spans": label.source_spans.len(), "clusters": label.clusters.len(),
|
||||||
|
"overlap_memberships": label.field_index.clusters_by_span.iter().map(Vec::len).sum::<usize>(),
|
||||||
|
"exact_original_result_including_rectangle_bits": "passed",
|
||||||
"full_coverage_and_exact_geometry": "passed",
|
"full_coverage_and_exact_geometry": "passed",
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,381 @@
|
|||||||
|
//! Exact differential oracle from 4c9bc87e4e66bba573dd63b23df5ea039f85d0ca.
|
||||||
|
//! The old method body is retained verbatim; only its name/visibility changes.
|
||||||
|
//! Its unchanged validation/translation helpers remain in the parent module.
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) fn assert_exact(actual: &FieldLayout, expected: &FieldLayout) {
|
||||||
|
assert_eq!(actual, expected);
|
||||||
|
// PartialEq deliberately equates signed zero; the observable first box
|
||||||
|
// must nevertheless retain exactly the old representation and order.
|
||||||
|
for (actual, expected) in actual.rectangles.iter().zip(&expected.rectangles) {
|
||||||
|
let bits = |r: &Rectangle| [r.x, r.y, r.width, r.height].map(f32::to_bits);
|
||||||
|
assert_eq!(bits(actual), bits(expected));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compare(
|
||||||
|
label: &Label,
|
||||||
|
session: &RenderSession,
|
||||||
|
zoom: f32,
|
||||||
|
position: Point,
|
||||||
|
field: Range<usize>,
|
||||||
|
) {
|
||||||
|
let old = label.original_field_layout(session, zoom, position, field.clone());
|
||||||
|
let new = label.field_layout(session, zoom, position, field);
|
||||||
|
match (new, old) {
|
||||||
|
(Ok(new), Ok(old)) => assert_exact(&new, &old),
|
||||||
|
(new, old) => assert_eq!(new, old),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn all_fields(label: &Label, session: &RenderSession, position: Point) {
|
||||||
|
let boundaries: Vec<_> = label
|
||||||
|
.source
|
||||||
|
.char_indices()
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.chain(std::iter::once(label.source.len()))
|
||||||
|
.collect();
|
||||||
|
for &start in &boundaries {
|
||||||
|
for &end in &boundaries {
|
||||||
|
compare(label, session, label.measured_zoom, position, start..end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn synthetic(source: &str, retained: usize, clusters: Vec<FieldCluster>) -> (RenderSession, Label) {
|
||||||
|
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||||
|
let normalized = Normalized::new(source);
|
||||||
|
let field_index = FieldIndex::new(&normalized.spans, &clusters);
|
||||||
|
let label = Label {
|
||||||
|
source: source.into(),
|
||||||
|
displayed: normalized.text,
|
||||||
|
retained_source: 0..source.len(),
|
||||||
|
bounds: Rectangle::with_size(Size::ZERO),
|
||||||
|
style: Style::default(),
|
||||||
|
measured_session: session.clone(),
|
||||||
|
measured_zoom: 1.,
|
||||||
|
clusters,
|
||||||
|
source_spans: normalized.spans,
|
||||||
|
retained_normalized: retained,
|
||||||
|
field_index,
|
||||||
|
};
|
||||||
|
(session, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cluster(range: Range<usize>, rectangles: Vec<Rectangle>, missing: bool) -> FieldCluster {
|
||||||
|
FieldCluster {
|
||||||
|
normalized: range,
|
||||||
|
rectangles,
|
||||||
|
missing,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn indexed_lookup_matches_original_for_real_shaping_and_every_field_boundary() {
|
||||||
|
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||||
|
let sources = [
|
||||||
|
"",
|
||||||
|
" \t\n ",
|
||||||
|
"ffi office",
|
||||||
|
" \talpha \r\n beta ",
|
||||||
|
"a\u{301}\u{fe0f} b\u{2060}c",
|
||||||
|
"אבג abc דהו",
|
||||||
|
"A😀Z",
|
||||||
|
];
|
||||||
|
let mut nonempty = 0;
|
||||||
|
for source in sources {
|
||||||
|
for shaping in [Shaping::Auto, Shaping::Advanced] {
|
||||||
|
for zoom in [0.5, 1.75] {
|
||||||
|
let mut cache = Cache::default();
|
||||||
|
for width in [0., 25., 500.] {
|
||||||
|
let label = cache
|
||||||
|
.resolve(
|
||||||
|
&session,
|
||||||
|
source,
|
||||||
|
width,
|
||||||
|
zoom,
|
||||||
|
Style {
|
||||||
|
shaping,
|
||||||
|
..Style::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
nonempty += usize::from(!label.clusters.is_empty());
|
||||||
|
all_fields(&label, &session, Point::ORIGIN);
|
||||||
|
all_fields(&label, &session, Point::new(-37.25, 16.125));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
nonempty >= sources.len(),
|
||||||
|
"must compare actual shaped geometry as well as empty labels"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn indexed_lookup_matches_original_for_overlapping_and_reordered_clusters() {
|
||||||
|
// Normalization is "a + acute + VS16 + space + b + space + c".
|
||||||
|
// Cluster intervals overlap, nest, span separators, and occur in a
|
||||||
|
// deliberately non-source/non-visual order. This tests the join rather
|
||||||
|
// than assuming the simpler cluster partition of most actual fonts.
|
||||||
|
let source = " a\u{301}\u{fe0f} b c ";
|
||||||
|
assert_eq!(Normalized::new(source).text.len(), 10);
|
||||||
|
let a = Rectangle::new(Point::new(30., 3.), Size::new(4., 12.));
|
||||||
|
let b = Rectangle::new(Point::new(-20., -1.), Size::new(7., 11.));
|
||||||
|
let c = Rectangle::new(Point::new(9., 4.), Size::new(2., 10.));
|
||||||
|
let cases = vec![
|
||||||
|
cluster(7..10, vec![b, a], false),
|
||||||
|
cluster(0..6, vec![a, c, a], false),
|
||||||
|
cluster(1..3, vec![], false),
|
||||||
|
cluster(3..8, vec![c], true),
|
||||||
|
cluster(0..10, vec![b], false),
|
||||||
|
cluster(6..7, vec![c], false),
|
||||||
|
cluster(9..13, vec![a], false), // can extend into a synthetic ellipsis
|
||||||
|
cluster(3..3, vec![c], true), // empty interval cannot match anything
|
||||||
|
cluster(8..7, vec![b], true), // nor can a reversed interval
|
||||||
|
];
|
||||||
|
for reverse in [false, true] {
|
||||||
|
let mut clusters = cases.clone();
|
||||||
|
if reverse {
|
||||||
|
clusters.reverse();
|
||||||
|
}
|
||||||
|
for retained in [0, 1, 3, 6, 7, 8, 10] {
|
||||||
|
let (session, label) = synthetic(source, retained, clusters.clone());
|
||||||
|
all_fields(&label, &session, Point::new(-0., -0.));
|
||||||
|
all_fields(&label, &session, Point::new(0.125, -64.));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (session, priority) = synthetic(
|
||||||
|
"a\u{2060}b",
|
||||||
|
5,
|
||||||
|
vec![
|
||||||
|
cluster(0..1, vec![], false),
|
||||||
|
cluster(1..4, vec![a], true),
|
||||||
|
cluster(4..5, vec![b], true),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
all_fields(&priority, &session, Point::ORIGIN);
|
||||||
|
let result = priority
|
||||||
|
.field_layout(&session, 1., Point::ORIGIN, 0..5)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result.unresolved,
|
||||||
|
vec![
|
||||||
|
UnresolvedField {
|
||||||
|
source: 0..1,
|
||||||
|
reason: FieldIssue::NoAdvanceGeometry
|
||||||
|
},
|
||||||
|
UnresolvedField {
|
||||||
|
source: 1..4,
|
||||||
|
reason: FieldIssue::NonPaintingScalar
|
||||||
|
},
|
||||||
|
UnresolvedField {
|
||||||
|
source: 4..5,
|
||||||
|
reason: FieldIssue::MissingGlyph
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(result.covered.is_empty() && result.rectangles.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn indexed_lookup_preserves_first_rectangle_bits_and_shared_status() {
|
||||||
|
let negative = Rectangle::new(Point::new(-0., -0.), Size::new(6., 12.));
|
||||||
|
let positive = Rectangle::new(Point::ORIGIN, Size::new(6., 12.));
|
||||||
|
assert_eq!(negative, positive, "Rectangle equality merges signed zero");
|
||||||
|
let other = Rectangle::new(Point::new(-20., 4.), Size::new(3., 12.));
|
||||||
|
let (session, label) = synthetic(
|
||||||
|
"a b",
|
||||||
|
3,
|
||||||
|
vec![
|
||||||
|
cluster(2..3, vec![negative, other], false),
|
||||||
|
cluster(0..1, vec![positive, negative], false),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let result = label
|
||||||
|
.field_layout(&session, 1., Point::new(-0., -0.), 0..3)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result.covered, vec![0..1, 2..3]);
|
||||||
|
assert!(!result.shared_cluster);
|
||||||
|
assert_eq!(result.rectangles.len(), 2);
|
||||||
|
assert_eq!(result.rectangles[0].x.to_bits(), (-0f32).to_bits());
|
||||||
|
assert_eq!(result.rectangles[0].y.to_bits(), (-0f32).to_bits());
|
||||||
|
assert_eq!(result.rectangles[1], other);
|
||||||
|
assert_exact(
|
||||||
|
&result,
|
||||||
|
&label
|
||||||
|
.original_field_layout(&session, 1., Point::new(-0., -0.), 0..3)
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let (session, shared) = synthetic("a b", 3, vec![cluster(0..3, vec![other], false)]);
|
||||||
|
let partial = shared
|
||||||
|
.field_layout(&session, 1., Point::ORIGIN, 0..1)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(partial.covered, vec![0..1]);
|
||||||
|
assert!(partial.shared_cluster);
|
||||||
|
let complete = shared
|
||||||
|
.field_layout(&session, 1., Point::ORIGIN, 0..3)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!complete.shared_cluster);
|
||||||
|
all_fields(&shared, &session, Point::ORIGIN);
|
||||||
|
|
||||||
|
// The same logical cluster crosses the retained prefix: its box remains
|
||||||
|
// shared while the source scalar after that prefix remains truncated.
|
||||||
|
let (session, clipped) = synthetic("abc", 2, vec![cluster(0..3, vec![other], false)]);
|
||||||
|
let clipped = clipped
|
||||||
|
.field_layout(&session, 1., Point::ORIGIN, 0..3)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(clipped.covered, vec![0..2]);
|
||||||
|
assert_eq!(clipped.truncated, vec![2..3]);
|
||||||
|
assert!(clipped.shared_cluster && clipped.partially_retained);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn indexed_lookup_preserves_error_precedence_and_overflow_rejection() {
|
||||||
|
let (session, label) = synthetic(
|
||||||
|
"éa",
|
||||||
|
3,
|
||||||
|
vec![cluster(
|
||||||
|
0..3,
|
||||||
|
vec![Rectangle::new(
|
||||||
|
Point::new(f32::MAX, 0.),
|
||||||
|
Size::new(f32::MAX, 1.),
|
||||||
|
)],
|
||||||
|
false,
|
||||||
|
)],
|
||||||
|
);
|
||||||
|
let stale = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
label.field_layout(&stale, f32::NAN, Point::new(f32::NAN, 0.), 7..8),
|
||||||
|
Err(Error::StaleRenderSession)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
label.field_layout(&session, 2., Point::new(f32::NAN, 0.), 7..8),
|
||||||
|
Err(Error::StaleZoom)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
label.field_layout(&session, 1., Point::new(f32::NAN, 0.), 7..8),
|
||||||
|
Err(Error::InvalidPosition)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
label.field_layout(&session, 1., Point::ORIGIN, 1..2),
|
||||||
|
Err(Error::InvalidFieldRange)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
label.field_layout(&session, 1., Point::ORIGIN, 0..3),
|
||||||
|
Err(Error::InvalidPosition)
|
||||||
|
);
|
||||||
|
// An unselected overflowing box must not turn a valid empty query into an error.
|
||||||
|
assert!(
|
||||||
|
label
|
||||||
|
.field_layout(&session, 1., Point::ORIGIN, 0..0)
|
||||||
|
.unwrap()
|
||||||
|
.rectangles
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
for guard in [&session, &stale] {
|
||||||
|
for zoom in [1., 2., f32::NAN] {
|
||||||
|
for position in [
|
||||||
|
Point::ORIGIN,
|
||||||
|
Point::new(f32::NAN, 0.),
|
||||||
|
Point::new(f32::MAX, 0.),
|
||||||
|
] {
|
||||||
|
for range in [0..0, 0..3, 1..2, 3..2, 0..4] {
|
||||||
|
compare(&label, guard, zoom, position, range);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Label {
|
||||||
|
pub(super) fn original_field_layout(
|
||||||
|
&self,
|
||||||
|
session: &RenderSession,
|
||||||
|
zoom: f32,
|
||||||
|
position: Point,
|
||||||
|
field: Range<usize>,
|
||||||
|
) -> Result<FieldLayout, Error> {
|
||||||
|
self.check(session, zoom)?;
|
||||||
|
translated_bounds(position, Size::ZERO)?;
|
||||||
|
if field.start > field.end || self.source.get(field.clone()).is_none() {
|
||||||
|
return Err(Error::InvalidFieldRange);
|
||||||
|
}
|
||||||
|
let mut result = FieldLayout {
|
||||||
|
rectangles: vec![],
|
||||||
|
covered: vec![],
|
||||||
|
truncated: vec![],
|
||||||
|
unresolved: vec![],
|
||||||
|
partially_retained: false,
|
||||||
|
shared_cluster: false,
|
||||||
|
};
|
||||||
|
let mut selected = vec![false; self.clusters.len()];
|
||||||
|
for span in &self.source_spans {
|
||||||
|
if span.whitespace || !overlaps(&span.source, &field) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Source spans for text are exact UTF-8 scalars, not intervals
|
||||||
|
// between ends (which could assign trimmed whitespace to text).
|
||||||
|
if span.normalized.end > self.retained_normalized {
|
||||||
|
append_range(&mut result.truncated, span.source.clone());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let matching: Vec<_> = self
|
||||||
|
.clusters
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, cluster)| overlaps(&cluster.normalized, &span.normalized))
|
||||||
|
.collect();
|
||||||
|
result.shared_cluster |= matching.iter().any(|(_, cluster)| {
|
||||||
|
cluster.normalized.end > self.retained_normalized
|
||||||
|
|| self.source_spans.iter().any(|other| {
|
||||||
|
overlaps(&other.normalized, &cluster.normalized)
|
||||||
|
&& !overlaps(&other.source, &field)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let issue = if span.non_painting {
|
||||||
|
Some(FieldIssue::NonPaintingScalar)
|
||||||
|
} else if matching.iter().any(|(_, cluster)| cluster.missing) {
|
||||||
|
Some(FieldIssue::MissingGlyph)
|
||||||
|
} else if matching.is_empty()
|
||||||
|
|| matching
|
||||||
|
.iter()
|
||||||
|
.all(|(_, cluster)| cluster.rectangles.is_empty())
|
||||||
|
{
|
||||||
|
Some(FieldIssue::NoAdvanceGeometry)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(reason) = issue {
|
||||||
|
result.unresolved.push(UnresolvedField {
|
||||||
|
source: span.source.clone(),
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
append_range(&mut result.covered, span.source.clone());
|
||||||
|
for (index, _) in matching {
|
||||||
|
selected[index] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (cluster, selected) in self.clusters.iter().zip(selected) {
|
||||||
|
if !selected {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for bounds in &cluster.rectangles {
|
||||||
|
let translated = translated_bounds(
|
||||||
|
Point::new(position.x + bounds.x, position.y + bounds.y),
|
||||||
|
bounds.size(),
|
||||||
|
)?;
|
||||||
|
if !result.rectangles.contains(&translated) {
|
||||||
|
result.rectangles.push(translated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.partially_retained = !result.truncated.is_empty()
|
||||||
|
&& (!result.covered.is_empty() || !result.unresolved.is_empty());
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Indexed field lookup experiment
|
||||||
|
|
||||||
|
This unwired experiment extends source `4c9bc87e4e66bba573dd63b23df5ea039f85d0ca`. It does not adopt measured labels or field navigation in the renderer. This revision has **not been compiled or executed**. The baseline debug diagnostic reported roughly 2.4–6.6 seconds for a 4096-scalar plain field and 1.3–1.9 seconds for a 4096-scalar combining-heavy field; these are motivation, not results for this patch.
|
||||||
|
|
||||||
|
`Label` now stores an overlap index built from the winning shaped buffer's clusters and the existing ordered, disjoint normalized scalar spans. For S spans, C clusters, and I actual span/cluster overlap memberships, index construction takes O(C log S + I) work and O(S + C + I) storage. A lookup visits the source spans and their indexed matches, then emits selected cluster rectangles in the original cluster/rectangle order. Hash membership replaces repeated linear rectangle searches. Signed zeros share a deduplication key, as with `Rectangle::PartialEq`, while the first original rectangle's bits remain unchanged. Hash iteration never determines output order.
|
||||||
|
|
||||||
|
Per-cluster source end/start extrema preserve the old shared-cluster predicate, including whitespace and nonpainting spans. Source scalar overlap, truncation, missing-glyph and nonpainting precedence, partial/shared flags, invalid-range errors, session/font/zoom guards, and position/overflow rejection are unchanged. No new length limit is introduced. Index storage can be dense for intentionally overlapping synthetic clusters. Existing shaping and `field_clusters` extraction are unchanged, including that extractor's per-cluster linear rectangle deduplication; this is not a claim of globally linear shaping or preparation.
|
||||||
|
|
||||||
|
The test-only `Label::original_field_layout` retains the baseline method body verbatim. Four new normal tests compare every `FieldLayout` field and every rectangle's exact component bits and position in the output:
|
||||||
|
|
||||||
|
- Real Auto/Advanced shaping at two zooms and three widths, over every legal UTF-8 boundary pair, including scalar boundaries inside combining graphemes.
|
||||||
|
- Overlapping, reordered, whitespace-spanning, missing, empty and reversed cluster ranges; partial retention and nonpainting/coverage precedence.
|
||||||
|
- First-occurrence signed-zero bits, shared clusters and truncation.
|
||||||
|
- Guard/error precedence, malformed fields, nonfinite positions and overflow.
|
||||||
|
|
||||||
|
The existing ignored `field_lookup_4096_scalars_and_combining_heavy_full_coverage` diagnostic retains its two inputs and independent Paragraph coverage/rectangle oracle. It now times the original lookup separately, three indexed queries on the retained label, and three fresh-cache resolve-plus-lookup operations. Exact old/new results and independent complete rectangle sets are checked outside timed intervals. The original lookup precedes indexed samples; font caches are warm. These are debug experiment measurements, not a latency guarantee or a cold-process benchmark. Fresh resolution includes index construction so its cost cannot disappear into an unmeasured preprocessing step.
|
||||||
|
|
||||||
|
The isolated harness should list 29 tests: 25 normal and four ignored. The production desktop does not register this module. Reuse the explicit dependency-bound harness recipe for the baseline, replacing its source binding with this revision and adding `scenario_label/lookup_tests.rs` to the input closure. Required execution is the normal suite plus these three ignored tests, each in a fresh process:
|
||||||
|
|
||||||
|
```text
|
||||||
|
scenario_label::field_tests::advanced_ligature_partial_field_uses_whole_shared_cluster_not_fraction
|
||||||
|
scenario_label::field_tests::auto_ascii_to_ellipsis_advanced_transition_uses_final_buffer
|
||||||
|
scenario_label::tests::actual_font_load_rejects_retained_label_and_old_session
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the timing diagnostic separately with `--exact scenario_label::field_tests::field_lookup_4096_scalars_and_combining_heavy_full_coverage --ignored --nocapture --test-threads=1`. It remains optional functional-gate evidence; its exact full-coverage assertions are not waived. The new differential filter is `scenario_label::lookup_tests::` (four tests). The pinned-font checks retain their existing isolated-process contract. Other real-shaping differential cases use the current system default font and compare both methods on the same resolved buffer; they do not establish cross-machine glyph identity.
|
||||||
Reference in New Issue
Block a user