From 9e25e978e56582dc1bda47d04aece8f9642f8942 Mon Sep 17 00:00:00 2001 From: Mikael Tollefsen Date: Tue, 20 Oct 2026 01:07:31 +0200 Subject: [PATCH] Page text on Linux is hinted like FreeType's hintslight --- ROADMAP.md | 6 +- crates/fonts/src/fontdb.rs | 145 ++++++++++++++++++++++++++++++++++- crates/render/src/lib.rs | 96 ++++++++++++++++++++++- crates/ui-egui/src/canvas.rs | 13 +++- docs/ui-parity.md | 4 +- 5 files changed, 256 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5a9fbf09fe..3a94812568 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,8 +2,8 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** trivial (progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** trivial (page text on Linux is hinted like Firefox draws text there; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (page text on Linux is hinted like Firefox draws text there; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and @@ -141,6 +141,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-10-19 | Page text on Linux and the BSDs is no longer soft: it is hinted the way FreeType's `hintslight` draws text in Firefox and other apps there, its autohinter putting baselines, x-heights and cap heights on whole pixels without moving anything sideways, so lines break as before. Measured against headless Firefox on Arial at 21.27 px: the same rows of pixels and the same weight. Re-rendering costs the same; the first render at a new zoom about 1.5 ms more per page. macOS, Windows and exports are unchanged. | | 2026-10-19 | Save As, Export and Open no longer do nothing on Linux desktops whose file chooser portal fails to show its dialog (#2999): a "cancel" quicker than anyone could make one asks again without the parent window, then opens the built-in file browser. | | 2026-10-18 | Long navigation headings wrap onto further lines inside the resizable pane instead of ending in an ellipsis: the row grows with the text and the pane keeps its width (#759, #3015). | | 2026-10-18 | Clearing a font query with no matches keeps Recently Used Fonts visible at the top of a scrollable list. The initial scroll position no longer skips its heading; measured keyboard scrolling is unchanged (#2068). | @@ -506,6 +507,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-19 | trivial | Progress log: page text on Linux is hinted like Firefox draws text there | | 2026-10-19 | trivial | Progress log: forms protection keeps its content controls: no inserting, Properties or Remove Content Control | | 2026-10-19 | trivial | Progress log: text typed into a content control takes the control's formatting; localized placeholder styles recognised | | 2026-10-18 | trivial | Progress log: long navigation headings wrap within the pane instead of ending in an ellipsis (#3015) | diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 798ec6aca3..72f48bdb91 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -7,7 +7,7 @@ use std::sync::{Arc, Mutex, RwLock}; use kurbo::BezPath; use skrifa::instance::{Location, LocationRef, Size}; -use skrifa::outline::{DrawSettings, Engine, HintingInstance, HintingOptions, OutlinePen, Target}; +use skrifa::outline::{DrawSettings, Engine, GlyphStyles, HintingInstance, HintingOptions, OutlineGlyphFormat, OutlinePen, SmoothMode, Target}; use skrifa::raw::FileRef; use skrifa::string::StringId; use skrifa::{GlyphId, MetadataProvider}; @@ -86,6 +86,8 @@ pub struct FontFace { bmp: std::sync::OnceLock>, // Some legacy CJK fonts assemble their contours with TrueType instructions. outline_hinter: std::sync::OnceLock>, + /// The autohinter's analysis of the font for screen hinting, made once for every size. + glyph_styles: std::sync::OnceLock, } /// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of @@ -204,6 +206,37 @@ impl FontFace { } pen.0 } + /// Glyph `gid`'s outline hinted for screen text at `ppem` device pixels per em, in device + /// pixels, y-down (flipped), or `None` when the face can't be hinted. The hinting is light, + /// as FreeType's `hintslight`: horizontal edges (baseline, x-height, cap height) move onto + /// whole pixels, nothing moves sideways, so advances and layout stay as they are. + fn hinted_glyph_outline(&self, gid: u32, hinter: &HintingInstance) -> Option { + let f = self.skrifa()?; + let g = f.outline_glyphs().get(GlyphId::new(gid))?; + let mut pen = FlipPen(BezPath::new()); + g.draw(DrawSettings::hinted(hinter, false), &mut pen).map_err(|e| log::warn!("Cannot hint glyph {gid} in {}: {e}", self.family)).ok()?; + Some(pen.0) + } + + /// A light hinting instance for `ppem` device pixels per em, with the engine FreeType's light + /// hinting picks: the autohinter for TrueType outlines (their own instructions are ignored; + /// measured against Firefox on Linux, the autohinter matches its rows of pixels and the + /// TrueType instructions don't), the font's own hints for PostScript (CFF) outlines and for + /// fonts whose glyphs need their instructions to be assembled at all. + fn screen_hinter(&self, ppem: f32) -> Option { + let f = self.skrifa()?; + let outlines = f.outline_glyphs(); + let target = Target::Smooth { mode: SmoothMode::Light, symmetric_rendering: true, preserve_linear_metrics: true }; + let engine = if outlines.format() == Some(OutlineGlyphFormat::Glyf) && !outlines.require_interpreter() { + Engine::Auto(Some(self.glyph_styles.get_or_init(|| GlyphStyles::new(&outlines)).clone())) + } else { + Engine::Interpreter + }; + HintingInstance::new(&outlines, Size::new(ppem), self.location(), HintingOptions { engine, target }) + .map_err(|e| log::warn!("Cannot hint {} at {ppem} px: {e}", self.family)) + .ok() + } + /// Is `family` this face's typographic, legacy or full name? pub fn named(&self, family: &str) -> bool { self.family.eq_ignore_ascii_case(family) @@ -328,6 +361,10 @@ struct CatalogEntry { pub struct FontDb { faces: RwLock>>, outlines: Mutex>>, + /// Screen-hinted outlines per (face, glyph, ppem bits); `None` where the face can't be hinted. + hinted: Mutex>>>, + /// Light hinting instances per (face, ppem bits); `None` where the face can't be hinted. + hinters: Mutex>>>, /// Fallback faces found per (character, excluded face), valid while the face count is the /// one stored (faces are only ever added). Layout asks once per uncovered character, and the /// search sorts every loaded face, so without this text in a script the requested font lacks @@ -608,6 +645,11 @@ const FALLBACK_CACHE_MAX: usize = 20_000; static NEXT_ID: AtomicU32 = AtomicU32::new(1); const OUTLINE_CACHE_MAX: usize = 50_000; +/// Hinting instances kept (one per face and text size on screen). +const HINTER_CACHE_MAX: usize = 256; +/// Sizes hinted, in device pixels per em: below, text is too small to read anyway; above, the +/// half-pixel hinting moves is invisible (and each size would cost a cache entry). +const HINT_PPEM: std::ops::RangeInclusive = 4.0..=96.0; fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option { ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty())) @@ -900,6 +942,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, legacy index, bmp: std::sync::OnceLock::new(), outline_hinter: std::sync::OnceLock::new(), + glyph_styles: std::sync::OnceLock::new(), }) } @@ -972,6 +1015,8 @@ impl FontDb { Self { faces: RwLock::new(faces), outlines: Mutex::new(HashMap::new()), + hinted: Mutex::new(HashMap::new()), + hinters: Mutex::new(HashMap::new()), fallbacks: RwLock::new(FallbackCache::default()), run_fallbacks: RwLock::new(RunFallbackCache::default()), #[cfg(not(target_arch = "wasm32"))] @@ -1789,6 +1834,43 @@ impl FontDb { cache.insert(key, p.clone()); p } + + /// Glyph outline hinted for the screen at `ppem` device pixels per em ([`HINT_PPEM`]), in + /// device pixels, y-down, for drawing at a baseline on a whole pixel; cached per (face, glyph, + /// size). `None` for sizes out of range and faces that can't be hinted: draw [`Self::outline`]. + pub fn hinted_outline(&self, face: &FontFace, gid: u32, ppem: f32) -> Option> { + if !HINT_PPEM.contains(&ppem) { + return None; + } + let key = (face.id, gid, ppem.to_bits()); + if let Some(p) = self.hinted.lock().unwrap_or_else(|e| e.into_inner()).get(&key) { + return p.clone(); + } + let hinter = { + let hkey = (face.id, ppem.to_bits()); + let known = self.hinters.lock().unwrap_or_else(|e| e.into_inner()).get(&hkey).cloned(); + match known { + Some(h) => h, + None => { + // Built outside the lock: running a font's hinting programs takes a while. + let h = face.screen_hinter(ppem).map(Arc::new); + let mut cache = self.hinters.lock().unwrap_or_else(|e| e.into_inner()); + if cache.len() >= HINTER_CACHE_MAX { + cache.clear(); + } + cache.insert(hkey, h.clone()); + h + } + } + }; + let p = hinter.and_then(|h| face.hinted_glyph_outline(gid, &h)).map(Arc::new); + let mut cache = self.hinted.lock().unwrap_or_else(|e| e.into_inner()); + if cache.len() >= OUTLINE_CACHE_MAX { + cache.clear(); + } + cache.insert(key, p.clone()); + p + } } struct FlipPen(BezPath); @@ -1868,6 +1950,67 @@ mod tests { } /// The issue's run (#1549): Thai letters and digits, Latin letters, digits and the baht sign. + #[test] + fn screen_hinting_puts_horizontal_edges_on_whole_pixels_and_moves_nothing_sideways() { + let db = FontDb::global(); + let face = db.face(FALLBACK_FAMILY, "Regular"); + let x = face.glyph_for('x'); + // 11 pt at 100 % zoom on a 145 % desktop: a fractional size, as on most screens. + let ppem = 11.0 * 96.0 / 72.0 * 1.45; + let hinted = db.hinted_outline(&face, x, ppem).expect("Source Sans 3 can be hinted"); + let plain = db.outline(&face, x); + let k = f64::from(ppem) / face.upem; + let (h, u) = (kurbo::Shape::bounding_box(&*hinted), kurbo::Shape::bounding_box(&*plain)); + // The x-height (the top, y-down) and the baseline sit on the pixel grid… + assert!((h.y0 - h.y0.round()).abs() < 0.02, "x-height at {}", h.y0); + assert!((h.y1 - h.y1.round()).abs() < 0.02, "baseline at {}", h.y1); + assert!((u.y0 * k - (u.y0 * k).round()).abs() > 0.02, "the unhinted x-height is off the grid: {}", u.y0 * k); + // …and the sides stay where the font puts them, so advances and line breaks don't change. + assert!((h.x0 - u.x0 * k).abs() < 0.01 && (h.x1 - u.x1 * k).abs() < 0.01, "{h:?} vs {:?}", u.scale_from_origin(k)); + // Cached per size. + assert!(Arc::ptr_eq(&hinted, &db.hinted_outline(&face, x, ppem).expect("cached"))); + } + + #[test] + fn screen_hinting_uses_the_autohinter_even_for_fonts_with_their_own_instructions() { + // FreeType's light hinting (`hintslight`, Firefox on Linux) runs its autohinter on + // TrueType outlines and ignores their instructions: measured against Firefox, the + // instructions drew Arial's rows elsewhere and heavier. + let db = FontDb::global(); + let face = db.face(FALLBACK_FAMILY, "Regular"); + let font = face.skrifa().unwrap(); + let outlines = font.outline_glyphs(); + assert!(outlines.prefer_interpreter(), "Source Sans 3 carries TrueType instructions (ttfautohint)"); + let ppem = 11.0 * 96.0 / 72.0 * 1.45; + let target = Target::Smooth { mode: SmoothMode::Light, symmetric_rendering: true, preserve_linear_metrics: true }; + let drawn = |engine: Engine, gid: u32| { + let h = HintingInstance::new(&outlines, Size::new(ppem), face.location(), HintingOptions { engine, target }).unwrap(); + let mut pen = FlipPen(BezPath::new()); + outlines.get(GlyphId::new(gid)).unwrap().draw(DrawSettings::hinted(&h, false), &mut pen).unwrap(); + pen.0 + }; + let mut differs = false; + for c in "Every Thursday".chars().filter(|c| !c.is_whitespace()) { + let gid = face.glyph_for(c); + let ours = db.hinted_outline(&face, gid, ppem).unwrap(); + assert_eq!(*ours, drawn(Engine::Auto(None), gid), "{c}: the autohinter's outline"); + differs |= *ours != drawn(Engine::Interpreter, gid); + } + assert!(differs, "the font's own instructions would draw it differently"); + } + + #[test] + fn screen_hinting_refuses_hostile_sizes() { + let db = FontDb::global(); + let face = db.face(FALLBACK_FAMILY, "Regular"); + let x = face.glyph_for('x'); + for ppem in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -12.0, 0.0, 1.0, 3.99, 96.01, 1e9] { + assert!(db.hinted_outline(&face, x, ppem).is_none(), "{ppem}"); + } + // A glyph id past the font's glyphs has no outline to hint. + assert!(db.hinted_outline(&face, u32::MAX, 20.0).is_none_or(|o| o.elements().is_empty())); + } + const THAI_RUN: &str = "[D] น้ำ ค่ำ ปั้น ฝั่ง ปู่ ๑๒๓๔๕๖๗๘๙๐ ฿1,250.50"; #[test] diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b0416c11e6..35290993a9 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -409,6 +409,11 @@ pub struct RenderOptions { /// Thicken glyphs slightly the way macOS draws text on screen ("font smoothing"), so pages /// look like they do in other Mac apps. For on-screen rasters only: exports stay exact. pub text_darkening: bool, + /// Hint text for the screen the way FreeType's `hintslight` does on Linux desktops: glyph + /// baselines sit on whole pixels and their horizontal edges (x-height, cap height) are moved + /// onto the pixel grid, so text looks as crisp as in other apps there. Nothing moves + /// sideways, so lines break as without it. For on-screen rasters only: exports stay exact. + pub hinting: bool, /// Dark page (View › Switch Modes): every colour but pictures has its lightness inverted, so /// white paper turns black and black text white while hues stay the same. Screen only. pub dark: bool, @@ -493,6 +498,7 @@ impl Default for RenderOptions { paper: Rgb::WHITE, mark_color: Rgb(0x2B, 0x57, 0x9A), text_darkening: false, + hinting: false, dark: false, dark_paper: DARK_PAPER, } @@ -668,6 +674,13 @@ fn draw_watermark(ctx: &mut RenderContext, view: Affine, page: &Page, wm: &wordc } } +/// Does `view` map page points to device pixels without rotation, skew or mirroring, scaling both +/// axes alike? Only then do hinted outlines (made on the pixel grid) line up with the screen. +fn is_upright(view: Affine) -> bool { + let [a, b, c, d, e, f] = view.as_coeffs(); + [a, b, c, d, e, f].iter().all(|v| v.is_finite()) && b == 0.0 && c == 0.0 && a > 0.0 && (a - d).abs() <= a * 1e-6 +} + /// How far, in device pixels, macOS-style font smoothing pushes each side of a glyph outline out /// at `ppem` device pixels per em. Values from Pathfinder (MIT/Apache-2.0), which measured them /// against macOS: 1.21% of the pixel size horizontally and 1.25 times that vertically, each @@ -790,13 +803,19 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl let darken_px = if opts.text_darkening && *alpha >= 1.0 { stem_darkening(*size as f64 * px_per_pt) } else { 0.0 }; let darken = if darken_px > 0.0 && k * px_per_pt > 0.0 { 2.0 * darken_px / (k * px_per_pt) } else { 0.0 }; let outline_stroke = if *synth_bold { face.upem * 0.03 } else { 0.0 } + darken; + let join = if darken > 0.0 { kurbo::Join::Round } else { kurbo::Join::Miter }; if outline_stroke > 0.0 { - let join = if darken > 0.0 { kurbo::Join::Round } else { kurbo::Join::Miter }; ctx.set_stroke(kurbo::Stroke::new(outline_stroke).with_join(join)); } // Emoji and other colour glyphs (#1269) are drawn from the font's colour data. let colour = face.has_color_glyphs(); let ppem = *size as f64 * px_per_pt; + // Hinted outlines are in device pixels: only for an upright, unmirrored, evenly scaled + // view (not rotated text boxes), where the pixel grid lines up with the glyphs. + let hint_ppem = (opts.hinting && !colour && is_upright(view)).then_some(ppem as f32); + // The outline strokes in device pixels, for the hinted outlines. + let stroke_px = outline_stroke * k * px_per_pt; + let mut hinted_stroke = false; for (gid, x, y) in glyphs { let (gx, gy) = (*x as f64, *y as f64); // Written so a NaN position is skipped too. @@ -810,6 +829,23 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl ctx.set_paint(color(opts.ink(*c), *alpha)); continue; } + if let Some(o) = hint_ppem.and_then(|p| db.hinted_outline(face, *gid, p)) { + if o.elements().is_empty() { + continue; + } + // The baseline on a whole pixel, as the hinting assumes. + let at = view * kurbo::Point::new(gx, gy); + ctx.set_transform(Affine::translate((at.x, at.y.round())) * skew); + ctx.fill_path(&o); + if outline_stroke > 0.0 { + if !hinted_stroke { + ctx.set_stroke(kurbo::Stroke::new(stroke_px).with_join(join)); + hinted_stroke = true; + } + ctx.stroke_path(&o); + } + continue; + } let o = db.outline(face, *gid); if o.elements().is_empty() { continue; @@ -817,6 +853,10 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl ctx.set_transform(view * Affine::translate((gx, gy)) * skew * Affine::scale(k)); ctx.fill_path(&o); if outline_stroke > 0.0 { + if hinted_stroke { + ctx.set_stroke(kurbo::Stroke::new(outline_stroke).with_join(join)); + hinted_stroke = false; + } ctx.stroke_path(&o); } } @@ -1420,6 +1460,60 @@ mod tests { assert!(!RenderOptions::default().text_darkening); } + /// Inked pixels that are only partly covered (grey): an edge between two pixel rows. + fn grey(img: &Rendered) -> usize { + img.pixels.as_chunks::<4>().0.iter().filter(|p| (40..215).contains(&p[0])).count() + } + + /// The first and last pixel columns with any ink. + fn ink_columns(img: &Rendered) -> (usize, usize) { + let w = img.width as usize; + let inked: Vec = img.pixels.as_chunks::<4>().0.iter().enumerate().filter(|(_, p)| p[0] < 250).map(|(i, _)| i % w.max(1)).collect(); + (inked.iter().copied().min().unwrap_or(0), inked.iter().copied().max().unwrap_or(0)) + } + + #[test] + fn hinting_sharpens_screen_text_without_making_it_bolder_or_moving_it() { + let d = + Document::from_text("Every Thursday evening the old print shop fills with people who make things. Painters set up by the north windows."); + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); + // 100 % zoom on a 145 % desktop: glyph edges land between pixels. + let scale = 96.0 / 72.0 * 1.45; + let plain = render_page(&d, &l.pages[0], scale, &RenderOptions::default()); + let hinted = render_page(&d, &l.pages[0], scale, &RenderOptions { hinting: true, ..Default::default() }); + // Fewer grey pixels: the baseline, x-height and cap height fill whole pixel rows. + let (g0, g1) = (grey(&plain), grey(&hinted)); + assert!((g1 as f64) < g0 as f64 * 0.95, "grey pixels: plain {g0}, hinted {g1}"); + // The same weight, as FreeType's light hinting draws it: no darker than a few percent. + let (a, b) = (ink(&plain), ink(&hinted)); + assert!(b as f64 > a as f64 * 0.95 && (b as f64) < a as f64 * 1.12, "ink: plain {a}, hinted {b}"); + // Nothing moves sideways: the text starts and ends in the same pixel columns. + let ((l0, r0), (l1, r1)) = (ink_columns(&plain), ink_columns(&hinted)); + assert!(l0.abs_diff(l1) <= 1 && r0.abs_diff(r1) <= 1, "plain {l0}..{r0}, hinted {l1}..{r1}"); + // Off by default: exports and thumbnails are untouched. + assert!(!RenderOptions::default().hinting); + } + + #[test] + fn rotated_or_mirrored_views_are_not_hinted() { + let d = Document::from_text("Rotated text stays as the font draws it."); + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); + let hint = RenderOptions { hinting: true, ..Default::default() }; + for view in [ + Affine::translate((300.0, -50.0)) * Affine::rotate(0.3) * Affine::scale(1.9), + Affine::translate((600.0, 0.0)) * Affine::scale_non_uniform(-1.9, 1.9), + Affine::scale_non_uniform(1.9, 2.4), + ] { + let plain = render_region(&d, &l.pages[0], 600, 300, view, &RenderOptions::default()); + let hinted = render_region(&d, &l.pages[0], 600, 300, view, &hint); + assert!(ink(&plain) > 0, "{view:?}"); + assert_eq!(plain.pixels, hinted.pixels, "{view:?}"); + } + assert!(is_upright(Affine::translate((3.5, 7.25)) * Affine::scale(1.93))); + assert!(!is_upright(Affine::scale(f64::NAN))); + assert!(!is_upright(Affine::scale(0.0))); + } + #[test] fn page_picture_is_painted_behind_the_text() { let mut png = Vec::new(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index fabe1d43dc..5a75de00be 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3325,10 +3325,17 @@ fn caret_color_at(app: &mut WordApp, layout: &std::sync::Arc, page: u c } -/// Render options for page rasters shown on screen: on macOS, text is darkened the way the system -/// draws it, so a page looks as it does in other Mac apps (exports never are). +/// Render options for page rasters shown on screen, so a page looks as text does in the +/// platform's other apps (exports never change): on macOS, text is darkened the way the system +/// draws it; on Linux and the BSDs it is hinted lightly, as FreeType's `hintslight` draws it +/// there (not darkened: hinted text already has FreeType's weight, and darkening made it +/// look semi-bold). pub(crate) fn screen_render_options() -> wordcraft_render::RenderOptions { - wordcraft_render::RenderOptions { text_darkening: cfg!(target_os = "macos"), ..Default::default() } + wordcraft_render::RenderOptions { + text_darkening: cfg!(target_os = "macos"), + hinting: cfg!(all(unix, not(target_os = "macos"))), + ..Default::default() + } } /// The hyperlink under the pointer at (x, y) on `page`, where `pos` is the caret position the diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d4948067e1..706202dd96 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** trivial (page text on Linux and the BSDs is hinted like FreeType's hintslight; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch. Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our @@ -47,6 +47,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Touchpad smooth/inertial scroll | ✅ (#252) | ✅ | 90% | — | | Web Layout width | Text wraps to the whole work area (window width at the current zoom, 144–20,000 pt) and re-wraps once a window resize has settled (not on every frame of a drag) or at once on a zoom, keeping the caret in view; the sheet runs edge to edge with no centred page. Draft and Outline keep their 800 pt column; headless sessions keep 800 pt (#1876) | text across the window | 95% | — | | Scrollbar drag page tip | ✅ while the vertical scrollbar is dragged, a tip beside the pointer names the page at the top of the window in the status bar's words ("Page 7 of 10") (#2551, `canvas::tests::dragging_the_scrollbar_names_the_page_at_the_top`); its look and wording not yet compared with Word's | not compared | 70% | 0.5 | +| Page text on screen (Linux, BSD) | Lightly hinted as FreeType's `hintslight` draws it (Firefox and other apps there): FreeType's autohinter puts baselines, x-heights and cap heights on whole pixels, nothing moves sideways, so lines break as before. Measured against headless Firefox (Arial, 21.27 px): the same rows of pixels and weight. Not yet: subpixel (LCD) edges; the interface text (egui) is hinted separately. macOS keeps its darkened unhinted text; Windows and exports are unchanged | sharp document text | 90% | 4–8 (subpixel edges) | | Web and Draft Layout rendering | Very long pages render only visible full-resolution raster tiles instead of shrinking one huge texture to the GPU size limit; nearby tiles are cached for scrolling | sharp document text | 95% | — | | Reading Mode | Existing Read Mode ribbon button opens a read-only, viewport-column layout with animated horizontal paging, transient width/text/spacing controls, heading navigation (Navigation pane) and resize anchoring; print/export layout unchanged. The arrows, Page Up/Down and Escape work straight after entering it; only whole columns show, centred, at any width, text size or window size; the columns end above the page-turn bar (#1772) | reading-optimized view and navigation | 75% | 1–2 | | Status bar (page x of y, words, language, view buttons, zoom slider) | ✅ (the language is the caret's, by name; a click sets the proofing language, or opens Set Proofing Language… for any other) | ✅ | 85% | — | @@ -74,6 +75,7 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh | Date | Change | Summary | |---|---|---| +| 2026-10-19 | trivial | Page text on Linux and the BSDs is hinted like FreeType's `hintslight`: as sharp as in Firefox, same weight, same line breaks | | 2026-10-18 | trivial | Long navigation headings wrap onto further lines inside the pane instead of ending in an ellipsis; the row grows with the text and the pane still resizes (#759, #3015) | | 2026-10-18 | trivial | Font list starts at its first row after clearing a no-match query; recent heading remains visible (#2068) | | 2026-10-16 | trivial | Dragging the vertical scrollbar shows a tip beside the pointer with the page at the top of the window ("Page 7 of 10"); nothing said where the drag would land (#2551) |