mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-10 04:09:53 -06:00
Page text on Linux is hinted like FreeType's hintslight
This commit is contained in:
committed by
vangel76
parent
e6c5a701dd
commit
9e25e978e5
+4
-2
@@ -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) |
|
||||
|
||||
+144
-1
@@ -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<Box<[u64]>>,
|
||||
// Some legacy CJK fonts assemble their contours with TrueType instructions.
|
||||
outline_hinter: std::sync::OnceLock<Option<HintingInstance>>,
|
||||
/// The autohinter's analysis of the font for screen hinting, made once for every size.
|
||||
glyph_styles: std::sync::OnceLock<GlyphStyles>,
|
||||
}
|
||||
|
||||
/// 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<BezPath> {
|
||||
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<HintingInstance> {
|
||||
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<Vec<Arc<FontFace>>>,
|
||||
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
|
||||
/// Screen-hinted outlines per (face, glyph, ppem bits); `None` where the face can't be hinted.
|
||||
hinted: Mutex<HashMap<(u32, u32, u32), Option<Arc<BezPath>>>>,
|
||||
/// Light hinting instances per (face, ppem bits); `None` where the face can't be hinted.
|
||||
hinters: Mutex<HashMap<(u32, u32), Option<Arc<HintingInstance>>>>,
|
||||
/// 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<f32> = 4.0..=96.0;
|
||||
|
||||
fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
|
||||
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<Arc<BezPath>> {
|
||||
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]
|
||||
|
||||
@@ -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<usize> = 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();
|
||||
|
||||
@@ -3325,10 +3325,17 @@ fn caret_color_at(app: &mut WordApp, layout: &std::sync::Arc<DocLayout>, 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
|
||||
|
||||
+3
-1
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user