* #848: font list previews no longer load every family they show The Font box drew each entry in its own face through the full layout pipeline, which loaded the whole family into the font database for good (faces are never unloaded). Every trip through the list kept tens of megabytes. Entries are now drawn with FontDb::preview_face: a loaded family's face is shared, an installed one is read into a face the database doesn't keep and is freed when dropped, and render_text_line draws it without the shared outline cache. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Font preview test tolerates other tests loading fallbacks; gaps rows 1–3 de-duplicated, #848 noted in row 3 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
7dfa6b0ee2
commit
1350cb959b
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**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-11 · **Change:** 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 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-11 · **Change:** trivial (the Font list no longer keeps every previewed font in memory, #848; 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
|
||||
|
||||
WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and
|
||||
agent control. This page is the summary; the assessment is
|
||||
|
|
@ -164,6 +164,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
|||
| 2026-10-12 | Automatic paragraph spacing drops between list items, as in Word (16 cases measured in Word for Windows): bulleted items that ask for automatic space before and after now touch, as in NIH's monitoring plan |
|
||||
| 2026-10-12 | Rendering like Word for Windows, found by comparing real .gov documents page by page: text is kerned only where Kerning for fonts asks for it (line breaks now match Word's), and automatic paragraph spacing (`w:beforeAutospacing`/`w:afterAutospacing`, common in pasted web text) is 14 pt, dropped at the document's and cells' edges |
|
||||
| 2026-10-12 | Keyboard context menu (#821): Shift+F10 opens the page's right-click menu below the caret; `ui.contextMenu` |
|
||||
| 2026-10-12 | The Font list no longer keeps every family it previews in memory (#848): the Font box drew each family's name in its own face by loading the whole family for good, so every trip through the list kept tens of megabytes (1.47 GB after 40 changes on macOS); entries are now drawn from a face read for the preview alone and freed (120 families previewed: +17 MB RSS instead of +411 MB); only fonts actually used load |
|
||||
| 2026-10-12 | Scrolling a document with one very large table stays smooth (#811): pages are fingerprinted for the texture cache once per layout and only when shown (not every page every frame), the markup-area check is cached per revision, and textures far from the view are dropped. The issue's 1,096-page table: ~600 ms → ~2.4 ms idle frames, ~590 ms → ~4.5 ms scroll frames |
|
||||
| 2026-10-12 | Search (#819): Alt+Q, or Alt then Q, opens the Search box from anywhere with nothing typed into it; queries match every word; `tools.search` for agents |
|
||||
| 2026-10-12 | Section breaks (#852): Layout › Breaks with text selected puts the break before the selection and keeps the text, as Word does; it used to delete it, leaving the first section empty |
|
||||
|
|
@ -274,6 +275,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
|||
| 2026-10-12 | trivial | Progress log: Start screen at launch (#766) |
|
||||
| 2026-10-12 | trivial | Progress log: Alt+Q command search (#819); log table header fixed |
|
||||
| 2026-10-12 | trivial | Progress log: ribbon groups fit above their labels |
|
||||
| 2026-10-12 | trivial | Font list previews no longer grow memory (#848) |
|
||||
| 2026-10-12 | trivial | Progress log: section break keeps the selected text (#852) |
|
||||
| 2026-10-12 | trivial | Progress log: save prompt from the keyboard (#861) |
|
||||
| 2026-10-12 | trivial | Progress log: craft-fonts Simplified Chinese in documents |
|
||||
|
|
|
|||
|
|
@ -168,6 +168,17 @@ impl FontFace {
|
|||
pub fn id(&self) -> u32 {
|
||||
self.id
|
||||
}
|
||||
/// Glyph `gid`'s outline in font units, y-down (flipped), uncached: for faces the database
|
||||
/// doesn't keep (font list previews); others go through [`FontDb::outline`]'s cache.
|
||||
pub fn glyph_outline(&self, gid: u32) -> BezPath {
|
||||
let mut pen = FlipPen(BezPath::new());
|
||||
if let Some(f) = self.skrifa()
|
||||
&& let Some(g) = f.outline_glyphs().get(GlyphId::new(gid))
|
||||
{
|
||||
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), self.location()), &mut pen);
|
||||
}
|
||||
pen.0
|
||||
}
|
||||
/// Is `family` this face's typographic or legacy family name?
|
||||
pub fn named(&self, family: &str) -> bool {
|
||||
self.family.eq_ignore_ascii_case(family) || self.legacy_family.eq_ignore_ascii_case(family)
|
||||
|
|
@ -1197,6 +1208,71 @@ impl FontDb {
|
|||
.unwrap_or_else(last_resort_face)
|
||||
}
|
||||
|
||||
/// The face a font list entry draws `family`'s name in: its Regular face (or the closest
|
||||
/// style), without loading the family (#848). A loaded family's face is shared; an installed
|
||||
/// one is read into a face of its own that the database doesn't keep, so its data is freed when
|
||||
/// the caller drops it. Browsing the font list (hundreds of families) then costs no lasting
|
||||
/// memory; only fonts actually used load for good. `None` when no font has that name, or its
|
||||
/// file is too large to read just for a preview.
|
||||
pub fn preview_face(&self, family: &str) -> Option<Arc<FontFace>> {
|
||||
if let Some(f) = self.find(family, "Regular") {
|
||||
return Some(f);
|
||||
}
|
||||
let english = self.english_family(family).filter(|en| !en.eq_ignore_ascii_case(family));
|
||||
if let Some(f) = english.as_deref().and_then(|en| self.find(en, "Regular")) {
|
||||
return Some(f);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(f) = self.unshared_face(family).or_else(|| english.as_deref().and_then(|en| self.unshared_face(en))) {
|
||||
return Some(f);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The installed `family`'s face closest to Regular, read from its file into a face the
|
||||
/// database doesn't keep (see [`FontDb::preview_face`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn unshared_face(&self, family: &str) -> Option<Arc<FontFace>> {
|
||||
let (path, fam, style) = {
|
||||
let cat = self.read_catalog();
|
||||
let mut cands: Vec<&CatalogEntry> = cat.iter().filter(|c| c.family.eq_ignore_ascii_case(family)).collect();
|
||||
if cands.is_empty() {
|
||||
cands = cat.iter().filter(|c| c.legacy.eq_ignore_ascii_case(family)).collect();
|
||||
}
|
||||
let score = |c: &CatalogEntry| {
|
||||
if matches!(norm(&c.style).as_str(), "regular" | "normal" | "book" | "roman") {
|
||||
-1.0
|
||||
} else {
|
||||
(style_weight(&c.style) - 400.0).abs() + if style_italic(&c.style) { 1000.0 } else { 0.0 }
|
||||
}
|
||||
};
|
||||
let best = cands.into_iter().min_by(|a, b| score(a).total_cmp(&score(b)))?;
|
||||
(best.path.clone(), best.family.clone(), best.style.clone())
|
||||
};
|
||||
let data = Arc::new(read_fallback_file(&path)?);
|
||||
let found = enumerate_faces(&data).into_iter().find(|f| f.family.eq_ignore_ascii_case(&fam) && f.style.eq_ignore_ascii_case(&style))?;
|
||||
let mut face = make_face(FontBytes::Owned(data), found.index, found.family, found.style, found.legacy, found.coords)?;
|
||||
face.local_names = found.local;
|
||||
Some(Arc::new(face))
|
||||
}
|
||||
|
||||
/// Bytes of font data held by loaded faces (each file once; compiled-in fonts not counted).
|
||||
/// It grows as families load for use; font list previews leave it as is (#848).
|
||||
pub fn loaded_font_bytes(&self) -> usize {
|
||||
let mut seen: Vec<*const Vec<u8>> = Vec::new();
|
||||
let mut total = 0usize;
|
||||
for f in self.read_faces().iter() {
|
||||
if let FontBytes::Owned(d) = &f.bytes {
|
||||
let p = Arc::as_ptr(d);
|
||||
if !seen.contains(&p) {
|
||||
seen.push(p);
|
||||
total = total.saturating_add(d.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/// Is `family` available (loaded, or installed on the system)? English and localized family
|
||||
/// names both count.
|
||||
pub fn has_family(&self, family: &str) -> bool {
|
||||
|
|
@ -1211,7 +1287,7 @@ impl FontDb {
|
|||
}
|
||||
|
||||
/// Is a face named `family` (its typographic or legacy family) loaded?
|
||||
fn is_loaded(&self, family: &str) -> bool {
|
||||
pub fn is_loaded(&self, family: &str) -> bool {
|
||||
self.read_faces().iter().any(|f| f.named(family))
|
||||
}
|
||||
|
||||
|
|
@ -1569,13 +1645,7 @@ impl FontDb {
|
|||
if let Some(p) = self.outlines.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
||||
return p.clone();
|
||||
}
|
||||
let mut pen = FlipPen(BezPath::new());
|
||||
if let Some(f) = face.skrifa()
|
||||
&& let Some(g) = f.outline_glyphs().get(GlyphId::new(gid))
|
||||
{
|
||||
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen);
|
||||
}
|
||||
let p = Arc::new(pen.0);
|
||||
let p = Arc::new(face.glyph_outline(gid));
|
||||
let mut cache = self.outlines.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if cache.len() >= OUTLINE_CACHE_MAX {
|
||||
cache.clear();
|
||||
|
|
|
|||
|
|
@ -8,9 +8,15 @@ const FAMILY: &str = "Sysfont Sans3";
|
|||
|
||||
/// A bundled Source Sans 3 file renamed [`FAMILY`] (as long as the original name).
|
||||
fn renamed(file: &str) -> Vec<u8> {
|
||||
renamed_to(file, FAMILY)
|
||||
}
|
||||
|
||||
/// A bundled Source Sans 3 file renamed `family` (13 characters, as long as the original name).
|
||||
fn renamed_to(file: &str, family: &str) -> Vec<u8> {
|
||||
assert_eq!(family.len(), "Source Sans 3".len());
|
||||
let mut data = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap();
|
||||
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
||||
for (from, to) in [(utf16("Source Sans 3"), utf16(FAMILY)), (b"Source Sans 3".to_vec(), FAMILY.as_bytes().to_vec())] {
|
||||
for (from, to) in [(utf16("Source Sans 3"), utf16(family)), (b"Source Sans 3".to_vec(), family.as_bytes().to_vec())] {
|
||||
let mut i = 0;
|
||||
while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) {
|
||||
data[i + at..i + at + to.len()].copy_from_slice(&to);
|
||||
|
|
@ -405,3 +411,48 @@ fn east_asian_fonts_are_listed_for_the_asian_text_font_box() {
|
|||
assert!(db.face("Songti Test", "Regular").is_east_asian());
|
||||
assert_eq!(listed(&db), 1);
|
||||
}
|
||||
|
||||
/// #848: browsing the font list drew each family's name in its own face, loading every family
|
||||
/// shown for good (tens of megabytes per trip through the Font box). Preview faces are read for
|
||||
/// the preview alone: however many families are previewed, the database keeps no font data, and
|
||||
/// each preview face is freed when dropped.
|
||||
#[test]
|
||||
fn font_list_previews_load_nothing_for_good() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-sysfonts-{}-previews", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let families: Vec<String> = (0..40).map(|i| format!("Preview Fam{i:02}")).collect();
|
||||
for (i, f) in families.iter().enumerate() {
|
||||
std::fs::write(dir.join(format!("p{i}-Regular.ttf")), renamed_to("SourceSans3-Regular.ttf", f)).unwrap();
|
||||
std::fs::write(dir.join(format!("p{i}-Bold.ttf")), renamed_to("SourceSans3-Bold.ttf", f)).unwrap();
|
||||
}
|
||||
let db = FontDb::with_font_dirs(vec![dir.clone()]);
|
||||
let (faces, bytes) = (db.read_faces().len(), db.loaded_font_bytes());
|
||||
// Every family previewed many times over, as reopening the list does.
|
||||
for _ in 0..5 {
|
||||
for f in &families {
|
||||
let face = db.preview_face(f).expect("installed family previews");
|
||||
assert_eq!(face.family, *f);
|
||||
assert_eq!(face.style, "Regular", "the preview shows the Regular face");
|
||||
assert!(face.covers('P'));
|
||||
assert_eq!(Arc::strong_count(&face), 1, "nothing else keeps the preview face (or its data)");
|
||||
}
|
||||
}
|
||||
assert_eq!(db.read_faces().len(), faces, "previews load no faces");
|
||||
assert_eq!(db.loaded_font_bytes(), bytes, "previews keep no font data");
|
||||
assert!(db.preview_face("No Such Family").is_none());
|
||||
|
||||
// Using a font loads it once; previewing it again shares the loaded face.
|
||||
assert_eq!(db.face(&families[0], "Regular").family, families[0]);
|
||||
let used = db.loaded_font_bytes();
|
||||
assert!(used > bytes, "a used family's data is counted");
|
||||
let shared = db.preview_face(&families[0]).unwrap();
|
||||
assert!(Arc::strong_count(&shared) > 1, "a loaded family's preview is the shared face");
|
||||
for _ in 0..3 {
|
||||
for f in &families[1..] {
|
||||
drop(db.preview_face(f));
|
||||
}
|
||||
}
|
||||
assert_eq!(db.loaded_font_bytes(), used, "memory stays bounded across previews");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -412,6 +412,44 @@ pub fn render_region(doc: &Document, page: &Page, w: u32, h: u32, view: Affine,
|
|||
Rendered { width: w16 as u32, height: h16 as u32, pixels }
|
||||
}
|
||||
|
||||
/// One line of `text` in `face` at `size` points, black on white, in a `w`×`h` point raster at
|
||||
/// `scale` pixels per point, starting `margin` points in with the baseline `margin` + the face's
|
||||
/// ascent down: a font list entry. Outlines come straight from the face, not the shared outline
|
||||
/// cache, so a face read just for the preview leaves nothing behind once dropped (#848).
|
||||
pub fn render_text_line(face: &wordcraft_fonts::FontFace, text: &str, size: f32, w: f32, h: f32, margin: f32, scale: f32) -> Rendered {
|
||||
let fin = |v: f32, d: f32| if v.is_finite() && v >= 0.0 { v } else { d };
|
||||
let (size, w, h, margin) = (fin(size, 12.0).min(1000.0) as f64, fin(w, 1.0), fin(h, 1.0), fin(margin, 0.0).min(1000.0) as f64);
|
||||
let scale = if scale.is_finite() && scale > 0.0 { scale.min(16.0) } else { 1.0 };
|
||||
let (w16, h16) = (((w * scale).ceil() as u32).clamp(1, MAX_SIDE) as u16, ((h * scale).ceil() as u32).clamp(1, MAX_SIDE) as u16);
|
||||
let mut ctx = RenderContext::new_with(w16, h16, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.set_paint(color(Rgb(255, 255, 255), 1.0));
|
||||
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w16 as f64, h16 as f64));
|
||||
ctx.set_paint(color(Rgb(0, 0, 0), 1.0));
|
||||
let upem = if face.upem.is_finite() && face.upem > 0.0 { face.upem } else { 1000.0 };
|
||||
let k = size / upem;
|
||||
let view = Affine::scale(scale as f64);
|
||||
let ascent = if face.ascent.is_finite() { face.ascent.clamp(0.0, upem * 4.0) } else { upem * 0.8 };
|
||||
let baseline = margin + ascent * k;
|
||||
let mut x = margin;
|
||||
for g in wordcraft_fonts::shape(face, text, &[], |c| c) {
|
||||
let o = face.glyph_outline(g.gid);
|
||||
ctx.set_transform(view * Affine::translate((x + g.x_offset as f64 * k, baseline - g.y_offset as f64 * k)) * Affine::scale(k));
|
||||
ctx.fill_path(&o);
|
||||
x += g.x_advance as f64 * k;
|
||||
if x > w as f64 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
ctx.flush();
|
||||
let mut pixels = vec![0u8; w16 as usize * h16 as usize * 4];
|
||||
let mut res = Resources::new();
|
||||
if let Some(pm) = vello_cpu::PixmapMut::new(w16, h16, &mut pixels) {
|
||||
ctx.render(pm, &mut res);
|
||||
}
|
||||
Rendered { width: w16 as u32, height: h16 as u32, pixels }
|
||||
}
|
||||
|
||||
fn draw_watermark(ctx: &mut RenderContext, view: Affine, page: &Page, wm: &wordcraft_doc::Watermark, opts: &RenderOptions) {
|
||||
let r = wordcraft_fonts::word::resolve(&wm.font, false, false);
|
||||
let face = r.face;
|
||||
|
|
|
|||
|
|
@ -121,14 +121,7 @@ impl Previews {
|
|||
return None;
|
||||
}
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let mut img = snippet(
|
||||
&Document::new(),
|
||||
Paragraph::with_text(name, CharProps { font: Some(name.to_string()), size: Some(13.0), ..Default::default() }),
|
||||
240.0,
|
||||
20.0,
|
||||
ppp,
|
||||
2.0,
|
||||
)?;
|
||||
let mut img = font_entry_image(name, ppp);
|
||||
ink_mask(&mut img);
|
||||
let h = ui.ctx().load_texture(&key, img, egui::TextureOptions::LINEAR);
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(&key), h.clone()));
|
||||
|
|
@ -257,6 +250,20 @@ pub fn ink_mask(img: &mut egui::ColorImage) {
|
|||
}
|
||||
}
|
||||
|
||||
/// A font list entry: `name` in its own face, 240×20 pt. The face is read for the preview alone
|
||||
/// and dropped (#848): browsing the list used to load every family shown for good, so each trip
|
||||
/// through the Font box grew memory by tens of megabytes. A name the font has no glyphs for (a
|
||||
/// symbol font's), or a font that can't be read, shows in the default face.
|
||||
pub fn font_entry_image(name: &str, ppp: f32) -> egui::ColorImage {
|
||||
let db = wordcraft_fonts::FontDb::global();
|
||||
let face = db
|
||||
.preview_face(name)
|
||||
.filter(|f| name.chars().filter(|c| !c.is_whitespace()).all(|c| f.covers(c)))
|
||||
.unwrap_or_else(|| db.face(wordcraft_fonts::FALLBACK_FAMILY, "Regular"));
|
||||
let img = wordcraft_render::render_text_line(&face, name, 13.0, 244.0, 22.0, 2.0, ppp);
|
||||
egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_straight())
|
||||
}
|
||||
|
||||
/// Lay out `para` (with `base`'s styles) in a `w`×`h` pt box and rasterise it.
|
||||
pub fn snippet(base: &Document, para: Paragraph, w: f32, h: f32, ppp: f32, margin: f32) -> Option<egui::ColorImage> {
|
||||
snippet_blocks(base, vec![Block::Para(para)], w, h, ppp, margin, None, false)
|
||||
|
|
@ -513,6 +520,30 @@ pub fn table_style_tile(ui: &mut Ui, app: &mut WordApp, style: &str) -> Response
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #848: each trip through the Font box loaded every family it showed, for good. Entries are
|
||||
/// now drawn with a face read for the preview alone: installed families stay unloaded.
|
||||
#[test]
|
||||
fn font_menu_entries_leave_installed_families_unloaded() {
|
||||
let db = wordcraft_fonts::FontDb::global();
|
||||
let ink = |img: &egui::ColorImage| img.pixels.iter().filter(|p| p.r() < 128).count();
|
||||
assert!(ink(&font_entry_image("Source Sans 3", 1.0)) > 20, "the bundled face draws");
|
||||
assert!(ink(&font_entry_image("No Such Font 848", 1.0)) > 20, "an unknown font shows in the default face");
|
||||
// Tests running alongside share this database and may load a fallback (a math font for
|
||||
// an equation, a font for a script) at any time: leave the math fonts out, and allow a
|
||||
// stray load or two. Before #848 every family previewed was loaded.
|
||||
let shared = |f: &str| wordcraft_fonts::math::MATH_SUBSTITUTES.iter().any(|n| f.eq_ignore_ascii_case(n));
|
||||
let unloaded: Vec<String> = db.families().into_iter().filter(|f| !db.is_loaded(f) && !shared(f)).take(25).collect();
|
||||
let mut loaded = Vec::new();
|
||||
for f in &unloaded {
|
||||
let img = font_entry_image(f, 1.0);
|
||||
assert_eq!(img.size, [244, 22]);
|
||||
if db.is_loaded(f) {
|
||||
loaded.push(f.clone());
|
||||
}
|
||||
}
|
||||
assert!(loaded.len() <= 2, "previewing loaded {loaded:?}");
|
||||
}
|
||||
|
||||
/// #663: gallery previews are dimmed in the dark theme, unless they show Switch Modes' dark
|
||||
/// page; the light theme keeps a white page.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Where WordCraft falls short of Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (#811: scrolling a very large table no longer re-fingerprints every page each frame; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
The work list. One entry per shortfall, ranked by how much it stops a Word user from switching
|
||||
(**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent
|
||||
|
|
@ -14,10 +14,8 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr
|
|||
| # | Gap | B | Evidence | Impact | Hours | Doc |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures (including omitted/empty paragraph defaults and imported TOC range preservation); "DOCX fidelity corpus: not started" since 2026-10-06. Field reports fixed one by one: a cover page's theme-coloured shapes (#611), freeforms inside groups and text-box margins and Resize to fit (#743) | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 2 | **Ink and drawing canvases are dropped on open and lost on save, silently**; OLE objects show only their picture | B | No ink or drawing-canvas (`wpc`) handling in `crates/docx`; group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Documents with diagrams drawn in Word or pen notes lose content without warning | 4–8 to preserve ink and drawing canvases; 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) |
|
||||
| 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures (including omitted/empty paragraph defaults and imported TOC range preservation); "DOCX fidelity corpus: not started" since 2026-10-06 | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 2 | **Remaining native ink forms are dropped on open and lost on save, silently**; OLE objects show only their picture | B | Drawing-level native ink is preserved with its optional picture fallback; direct run-level content parts and ink nested in groups/canvases remain unsupported. Drawing canvases are read and written (#344); group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Documents with diagrams drawn in Word or pen notes lose content without warning | 4–8 to preserve remaining native ink forms; 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) |
|
||||
| 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170) (lag on GPUs without a DirectX 12 driver fixed, #316; 32-bit Windows exited at launch because DirectX 12 graphics pipeline creation failed, now OpenGL), freezes (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) |
|
||||
| 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170) (lag on GPUs without a DirectX 12 driver fixed, #316; 32-bit Windows exited at launch because DirectX 12 graphics pipeline creation failed, now OpenGL), freezes (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82); memory growth on every font change fixed (#848: the font list no longer loads each family it previews) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) |
|
||||
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options ignored; no page-by-page comparison harness | B | [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
|
||||
| 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; still missing: drag-and-drop moves (no text drag yet) and move options in Track Changes Options. Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes and table/section changes made in WordCraft. Opaque custom XML backing stores now survive saves; binding evaluation remains absent. | B | Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 6 | **No native printing** on desktop: File › Print opens the PDF in the system viewer, which prints (#286); File › Print has a preview, page range, odd/even, copies and collate (#641); printer choice, duplex and pages per sheet are still the system's; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) |
|
||||
|
|
@ -79,6 +77,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
|
|||
| 2026-10-11 | trivial | Tables: selection areas, insert buttons, move handle and double-click AutoFit on the page landed; ordinary drags over cells of several rows (a run of text, not a rectangle) added to the UI/UX gaps |
|
||||
| 2026-10-12 | trivial | #1: Word 365 cover page (#743): freeforms in groups keep their outline; text boxes keep their own margins and Resize shape to fit text, so no line is cut |
|
||||
| 2026-10-12 | trivial | #3: 32-bit Windows no longer exits at launch; DirectX 12 graphics pipelines fail to create, so that build starts on OpenGL |
|
||||
| 2026-10-12 | trivial | Gap #3: browsing the Font list no longer keeps every previewed family in memory (#848); each applied family still loads once |
|
||||
| 2026-10-12 | trivial | Custom dictionary persists across sessions (`custom-dictionary.txt` in the settings folder; #40) |
|
||||
| 2026-10-11 | trivial | #8 narrowed: runs in a proofing language without a dictionary are skipped instead of flagged against the English dictionary (#100); dictionaries still English-only |
|
||||
| 2026-10-11 | trivial | #16: DOCX open/save preserves independent complex-script formatting inheritance; RTL sections, tables and typography remain open |
|
||||
|
|
|
|||
Loading…
Reference in New Issue