Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 4ee514b103 Layout: look at the text after a decimal tab once, not once per cluster
Text after a decimal tab is shifted after each of its clusters, and each
shift walked the text again from the tab to find its first '.'. A long run
without a '.' (soft hyphens take no room, so it stays on one line) made a
single paragraph's line breaking quadratic. The scan now carries on from the
cluster it reached.
2026-11-02 22:23:48 -05:00
5 changed files with 66 additions and 57 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ pub(crate) use names::face_names;
pub use names::{LocalName, base_style, name_language_matches, system_font_dirs};
use names::{MAX_FILE_FACES, enumerate_faces, make_face};
#[cfg(test)]
use names::{MAX_NAME_CHARS, file_face_names, font_faces, mini_font, name};
use names::{file_face_names, font_faces, mini_font, name};
#[cfg(test)]
pub(crate) use names::{last_resort_face, legacy_family};
+3 -10
View File
@@ -61,16 +61,8 @@ pub(super) struct CatalogEntry {
/// The longest name a document's font table gives an embedded font that faces take on.
pub(super) const MAX_FAMILY_NAME: usize = 128;
/// The longest name read from a font's `name` table, in characters. A record can hold 64K
/// characters, and each face copies its family into its localized names and into every named
/// instance, so a small font could claim hundreds of megabytes of names. Real names run to a
/// few dozen characters.
pub(super) const MAX_NAME_CHARS: usize = 128;
pub(super) fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
ids.iter().find_map(|id| {
font.localized_strings(*id).english_or_first().map(|s| s.chars().take(MAX_NAME_CHARS).collect::<String>()).filter(|s| !s.is_empty())
})
ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
}
/// The family's names in other languages: localized typographic family names (name ID 16) stand
@@ -78,6 +70,7 @@ pub(super) fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<Strin
/// font has no typographic names). Bounded: a damaged font can list any number of records.
fn local_names(f: &skrifa::FontRef<'_>, family: &str, id1: Option<&str>) -> Vec<LocalName> {
const MAX_NAMES: usize = 64;
const MAX_CHARS: usize = 128;
let typographic = f.localized_strings(StringId::TYPOGRAPHIC_FAMILY_NAME).next().is_some();
let legacy = if typographic { id1 } else { Some(family) };
let mut out: Vec<LocalName> = Vec::new();
@@ -88,7 +81,7 @@ fn local_names(f: &skrifa::FontRef<'_>, family: &str, id1: Option<&str>) -> Vec<
if lang == "en" || lang.starts_with("en-") {
continue;
}
let name: String = s.chars().take(MAX_NAME_CHARS).collect::<String>().trim().to_string();
let name: String = s.chars().take(MAX_CHARS).collect::<String>().trim().to_string();
if name.is_empty() || name.eq_ignore_ascii_case(english) || out.iter().any(|n| n.lang == lang && n.name == name) {
continue;
}
+4 -34
View File
@@ -245,24 +245,15 @@ fn postscript_names_select_faces() {
/// A variable font holding just `name` and `fvar` tables: family "Var Test", one `wght` axis
/// and `instances` named instances, each with its own style name.
fn variable_font(instances: u16) -> Vec<u8> {
variable_font_named("Var Test", &[], instances)
}
/// [`variable_font`] with family `family`, and family names in other languages `local`
/// (Windows language ids).
fn variable_font_named(family: &str, local: &[(u16, &str)], instances: u16) -> Vec<u8> {
let mut names: Vec<(u16, u16, String)> = vec![(0x409, 1, family.into()), (0x409, 2, "Regular".into())];
names.extend(local.iter().map(|(lang, n)| (*lang, 1, n.to_string())));
names.extend((0..instances).map(|i| (0x409, 256 + i, format!("Style {i}"))));
// Records sorted by language, then name id.
names.sort_by_key(|(lang, id, _)| (*lang, *id));
let mut names: Vec<(u16, String)> = vec![(1, "Var Test".into()), (2, "Regular".into())];
names.extend((0..instances).map(|i| (256 + i, format!("Style {i}"))));
let mut name = vec![0, 0];
name.extend_from_slice(&(names.len() as u16).to_be_bytes());
name.extend_from_slice(&(6 + 12 * names.len() as u16).to_be_bytes());
let mut strings = Vec::new();
for (lang, id, text) in &names {
for (id, text) in &names {
let utf16: Vec<u8> = text.encode_utf16().flat_map(u16::to_be_bytes).collect();
for v in [3, 1, *lang, *id, utf16.len() as u16, strings.len() as u16] {
for v in [3, 1, 0x409, *id, utf16.len() as u16, strings.len() as u16] {
name.extend_from_slice(&v.to_be_bytes());
}
strings.extend(utf16);
@@ -366,27 +357,6 @@ fn the_draw_that_turns_hinting_off_is_redrawn_unhinted_for_the_outline_cache() {
assert_eq!(db.outline(&face, gid).elements(), tripping.path.elements());
}
#[test]
fn long_names_are_cut_before_faces_copy_them() {
// A name record can hold 64K characters, and every named instance copies the family and its
// localized names: the names are cut when read, so the copies stay small.
let long = "W".repeat(14_000);
let local = "Ж".repeat(14_000);
let font = variable_font_named(&long, &[(0x419, &local)], 32);
let faces = font_faces(&skrifa::FontRef::new(&font).unwrap(), 0);
assert_eq!(faces.len(), 32);
for f in &faces {
assert_eq!(f.family.chars().count(), MAX_NAME_CHARS);
assert!(f.legacy.chars().count() <= MAX_NAME_CHARS + " Style 31".len(), "{}", f.legacy.len());
assert_eq!(f.local.len(), 1);
assert_eq!(f.local[0].name.chars().count(), MAX_NAME_CHARS);
assert_eq!(f.local[0].english, f.family);
}
// Short names are kept whole.
let faces = font_faces(&skrifa::FontRef::new(&variable_font_named("Var Test", &[(0x419, "Вар Тест")], 2)).unwrap(), 0);
assert_eq!((faces[0].family.as_str(), faces[0].local[0].name.as_str()), ("Var Test", "Вар Тест"));
}
#[test]
fn named_instances_and_collection_members_are_capped() {
// #3716: a file's faces are capped before they are built, however the file multiplies them.
+36 -12
View File
@@ -90,6 +90,8 @@ struct Scan {
j: usize,
/// Tab cluster, stop, and x where the tab started.
pending_tab: Option<(usize, TabStop, f32)>,
/// The text after a pending decimal tab looked at so far for its '.'.
decimal: Option<DecimalScan>,
/// The width of the spaces so far on this line.
space_w: f32,
has_tab: bool,
@@ -111,17 +113,15 @@ fn takes_room(c: &Cluster) -> bool {
c.kind != ClKind::Marker && !(matches!(c.kind, ClKind::Object(_)) && c.adv == 0.0 && c.obj_h == 0.0)
}
/// The width up to the first '.' of the text after the tab at `tj`, up to cluster `j`.
fn width_to_decimal(pl: &ParaLayout, tj: usize, j: usize) -> Option<f32> {
let mut w = 0.0;
for k in tj + 1..=j {
let cc = pl.clusters.get(k)?;
if pl_text_at(cc) {
return Some(w);
}
w += cc.adv;
}
None
/// How far the text after a decimal tab has been looked at for its first '.' (see
/// [`Scan::width_to_decimal`]): the tab, the next cluster to look at, the width before it, and
/// whether that cluster is the '.'.
#[derive(Clone, Copy, Default)]
struct DecimalScan {
tab: usize,
next: usize,
width: f32,
found: bool,
}
/// The objects' exclusions mirrored across a line of `width`, for a right-to-left paragraph.
@@ -276,6 +276,7 @@ impl<'e> Breaker<'e> {
end: LineEnd::Para,
j: self.i,
pending_tab: None,
decimal: None,
space_w: 0.0,
has_tab: false,
skip: false,
@@ -839,6 +840,29 @@ impl Scan {
false
}
/// The width up to the first '.' of the text after the tab at `tj`, up to cluster `j`. The
/// text is shifted after each of its clusters, so the clusters already looked at are not
/// walked again: a long run without a '.' after a decimal tab is looked at once, not once
/// per cluster.
fn width_to_decimal(&mut self, pl: &ParaLayout, tj: usize, j: usize) -> Option<f32> {
let start = DecimalScan { tab: tj, next: tj + 1, width: 0.0, found: false };
let d = self.decimal.get_or_insert(start);
if d.tab != tj || d.next > j.saturating_add(1) {
*d = start;
}
while !d.found && d.next <= j {
crate::work::steps(1);
let cc = pl.clusters.get(d.next)?;
if pl_text_at(cc) {
d.found = true;
} else {
d.width += cc.adv;
d.next += 1;
}
}
d.found.then_some(d.width)
}
/// Text after a decimal/centre/right tab shifts as it grows.
fn shift_pending_tab(&mut self, pl: &mut ParaLayout) {
let Some((tj, stop, tx)) = self.pending_tab else { return };
@@ -847,7 +871,7 @@ impl Scan {
TabAlign::Right => stop.pos - tx - seg_w,
TabAlign::Center => stop.pos - tx - seg_w / 2.0,
// Width up to the first '.' in the segment.
TabAlign::Decimal => stop.pos - tx - width_to_decimal(pl, tj, self.j).unwrap_or(seg_w),
TabAlign::Decimal => stop.pos - tx - self.width_to_decimal(pl, tj, self.j).unwrap_or(seg_w),
_ => 0.0,
};
let shift = shift.max(0.0);
+22
View File
@@ -482,3 +482,25 @@ fn selecting_a_header_on_the_hinted_page_does_not_walk_every_page() {
eprintln!("hinted header: {small} steps for 20 pages, {big} for 160");
assert!(small > 0 && big <= small + 8, "the pages aren't walked: {small} → {big}");
}
/// A paragraph of a decimal tab at 200 pt followed by `text`.
fn after_decimal_tab(text: &str) -> Document {
use wordcraft_doc::props::{TabAlign, TabStop};
let mut d = Document::from_text(&format!("\t{text}"));
let at = Pos::body(0, 0);
d.format_paragraphs(&at, &at, &|p| p.tabs = Some(vec![TabStop { pos: 200.0, align: TabAlign::Decimal, leader: Default::default() }])).unwrap();
d
}
/// The text after a decimal tab was looked at again from the tab for its '.' after each of its
/// clusters, so a long run of clusters without a '.' (soft hyphens take no room, so they stay on
/// one line) took quadratic time.
#[test]
fn text_after_a_decimal_tab_is_looked_at_once() {
let opts = LayoutOptions::default();
// The text after the tab still ends at the stop, where it ended before.
let l = lay(&after_decimal_tab("12.5"), &opts);
let (Some(end), Some(start)) = (l.caret(&Pos::body(0, 5)), l.caret(&Pos::body(0, 0))) else { panic!("no carets") };
assert!((end.x - start.x - 200.0).abs() < 1.0, "the text ends at the stop: {} from {}", end.x, start.x);
assert_layout_linear("decimal tab", 2000, |n| after_decimal_tab(&format!("1{}", "\u{AD}".repeat(n))), &opts);
}