diff --git a/ROADMAP.md b/ROADMAP.md index 6fdcdba2d..f9e6e6443 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-14 · **Change:** trivial (progress log: HTML table cells and region paper for opened HTML, #1381; previously trivial: progress log: custom page size, #1433; 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-14 · **Change:** trivial (progress log: HTML table cells and region paper for opened HTML, #1381; previously trivial: progress log: dark-page highlights, #1252; 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. @@ -154,6 +154,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | The Equation tab's galleries, structure buttons and symbols keep the theme's colours on Windows: their pictures share a few textures written in place (no wipe of pictures already on screen, room for the built-ins at high display scales), instead of hundreds of small textures freed in bulk, which on DirectX 12 showed inverted, red or teal tiles (#1002) | | 2026-10-14 | HTML import (#1381): header cells (``) are bold unless their own CSS says otherwise, and `text-align` on a cell, its row, row group or table aligns the cell's paragraphs (a paragraph's own alignment wins), so `wordcraft-cli convert page.html out.docx` writes the bold header and `w:jc` right-aligned cells. HTML, Markdown and plain text, which give no page size, open on the region's paper (A4 in Italy, Letter in the US) like a new blank document, in the app and in `wordcraft-cli`; documents with their own page size keep it | | 2026-10-14 | Custom page sizes (#1433): Layout › Size ends with More Paper Sizes…, which opens Page Setup's new Paper group — a Paper size list (the named sizes, or Custom size) and Width and Height in the measurement unit; OK sets the size only when it changed, and a wider-than-tall size comes out landscape. `layout.size` now clamps each side to Word's 0.1"–22" and refuses a page the margins don't fit (the margins rule: 36 pt of text room), and Page Setup checks the size and margins together before applying either | +| 2026-10-13 | Highlighted text reads on a dark page (View › Switch Modes, #1252): highlights and character shading are dimmed, keeping their hue (yellow turns a dim olive, as in Word), and the text on them is drawn light (coloured text below 4.5:1 is drawn white, and underlines and strikethroughs follow the same rule), instead of white text on bright yellow; light page, print and exports unchanged | | 2026-10-13 | Full screen (#980): F11, or View › Window › Full Screen, fills the screen with the window (⌃⌘F on macOS); F11 again or Escape gives back the window as it was. Next Field, which Word puts on F11, keeps Word's Alt+F1 and Alt+Shift+F1 (and Shift+F11). The web app leaves full screen to the browser's own F11 | | 2026-10-13 | Double-clicking the top or bottom margin of a page creates its header or footer when it has none and puts the caret in it, as in Word (#960): the one that page shows (different first page, odd and even pages), only in Print Layout; one left empty is taken away again when you leave it, so nothing empty is saved. `insert.editHeader`/`insert.editFooter` take `{"page"}` for agents | | 2026-10-13 | Alt+numpad character codes, as in Word on Windows (#961): hold Alt, type a decimal code, release Alt — `0233` → é (Windows-1252), `233` → Θ (OEM code page 437; 850 in Western European locales and English outside the US), codes above 255 are Unicode (`8364` → €). Not on macOS; `text.typeCode` for agents | @@ -321,6 +322,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | trivial | Progress log: dragged shapes wrap in their new column (#884) | | 2026-10-14 | trivial | Progress log: HTML header cells, cell alignment and region paper for opened HTML/Markdown/text (#1381) | | 2026-10-14 | trivial | Progress log: custom page size from More Paper Sizes… and Page Setup (#1433) | +| 2026-10-13 | trivial | Progress log: highlighted text on a dark page (#1252) | | 2026-10-13 | trivial | Progress log: full screen on F11 (#980) | | 2026-10-13 | trivial | Progress log: interface audit back in CI (#1128) | | 2026-10-13 | trivial | Progress log: double-click the margin to create and edit a header or footer (#960) | diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index aa799bfda..2b16fa7b4 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -38,11 +38,17 @@ pub enum Draw { /// Byte range of `text` each glyph shows (one per glyph; a ligature's range covers all /// its characters). Empty when unknown: the PDF writer then guesses from the font. ranges: Vec>, + /// The text background (highlight or character shading) these glyphs are drawn on, if + /// any: a dark page dims it, so it picks the text colour against the dimmed colour. + text_bg: Option, }, Fill { rect: Rect, color: Rgb, alpha: f32, + /// A text background (highlight or character shading), which a dark page dims instead + /// of inverting so the text on it stays readable. Other fills (paragraph, cell, page) don't. + text_bg: bool, }, Line { x0: f32, @@ -53,6 +59,9 @@ pub enum Draw { color: Rgb, stroke: Stroke, alpha: f32, + /// The text background this line (an underline or strikethrough) is drawn on, as for + /// [`Draw::Glyphs`]; `None` for every other line. + text_bg: Option, }, Image { rect: Rect, @@ -506,7 +515,17 @@ fn change_bars(doc: &Document, page: &Page, m: &MarkupOptions, alpha: f32, out: || (mark_rev && line.end == LineEnd::Para); if changed { let top = y + (line.top - first.top); - out.push(Draw::Line { x0: bx, y0: top, x1: bx, y1: top + line.height, width: 0.75, color, stroke: Stroke::Solid, alpha }); + out.push(Draw::Line { + x0: bx, + y0: top, + x1: bx, + y1: top + line.height, + width: 0.75, + color, + stroke: Stroke::Solid, + alpha, + text_bg: None, + }); } } } @@ -533,6 +552,7 @@ fn rule(x0: f32, y0: f32, x1: f32, y1: f32, b: &Border, alpha: f32) -> Draw { color: b.color.unwrap_or(Rgb::BLACK), stroke: border_stroke(b.style), alpha, + text_bg: None, } } @@ -554,7 +574,7 @@ fn item(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, out: &mu /// effects follow all the turns it is drawn inside. fn item_in(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, outer: Spin, out: &mut Vec) { match it { - Placed::Fill { rect, color } => out.push(Draw::Fill { rect: *rect, color: *color, alpha }), + Placed::Fill { rect, color } => out.push(Draw::Fill { rect: *rect, color: *color, alpha, text_bg: false }), Placed::Rule { x0, y0, x1, y1, border } => out.push(rule(*x0, *y0, *x1, *y1, border, alpha)), Placed::Image { rect, media, crop, spin, .. } => { spun(*spin, *rect, vec![Draw::Image { rect: *rect, media: media.clone(), crop: *crop, alpha }], out) @@ -637,7 +657,12 @@ fn lines( } for (x0, x1) in pl.x_spans(li, a.max(line.start), b.min(line.stop)) { if x1 > x0 { - out.push(Draw::Fill { rect: Rect::new(x + x0, top, x1 - x0, line.height), color: Rgb(0xEF, 0xE3, 0xF7), alpha }); + out.push(Draw::Fill { + rect: Rect::new(x + x0, top, x1 - x0, line.height), + color: Rgb(0xEF, 0xE3, 0xF7), + alpha, + text_bg: false, + }); } } } @@ -651,7 +676,7 @@ fn lines( continue; } if let Some(h) = st.rc.highlight.or(st.rc.shading) { - out.push(Draw::Fill { rect: Rect::new(x + cx, top, (nx - cx).max(0.0), line.height), color: h, alpha }); + out.push(Draw::Fill { rect: Rect::new(x + cx, top, (nx - cx).max(0.0), line.height), color: h, alpha, text_bg: true }); } let b = st.rc.border; if let Some((ob, x0, x1)) = open.as_mut() @@ -689,6 +714,7 @@ fn lines( text: run.text.clone(), link: None, ranges: Vec::new(), + text_bg: None, }); } } @@ -708,6 +734,7 @@ fn lines( text: "-".into(), link: None, ranges: Vec::new(), + text_bg: None, }); } // Glyph runs grouped by style. @@ -809,6 +836,8 @@ fn lines( (None, Some(r)) => m.delete_color.unwrap_or_else(|| author(r)), _ => text_color(&rc.color, rc.shading.or(rc.highlight)), }; + // The highlight or shading drawn under this run (as filled in the backgrounds pass). + let text_bg = rc.highlight.or(rc.shading); let ins_mark = ins.map(|_| if moved_to { InsertMark::DoubleUnderline } else { m.insert_mark }); let del_mark = del.map(|_| if moved_from { DeleteMark::DoubleStrikethrough } else { m.delete_mark }); if del_mark == Some(DeleteMark::Caret) { @@ -825,6 +854,7 @@ fn lines( color, stroke: Stroke::Solid, alpha, + text_bg, }); out.push(Draw::Line { x0: cx, @@ -835,6 +865,7 @@ fn lines( color, stroke: Stroke::Solid, alpha, + text_bg, }); } continue; @@ -865,6 +896,7 @@ fn lines( text, link: rc.link.clone(), ranges, + text_bg, }); } // Decorations across the run (underline skips trailing spaces of the line). Right-to-left @@ -900,17 +932,17 @@ fn lines( if a < x0 - 0.01 || b > x1 + 0.01 { continue; // drawn with its own span } - out.push(Draw::Line { x0: a, y0: uy, x1: b, y1: uy, width: thick, color: ucolor, stroke: Stroke::Solid, alpha }); + out.push(Draw::Line { x0: a, y0: uy, x1: b, y1: uy, width: thick, color: ucolor, stroke: Stroke::Solid, alpha, text_bg }); } } else { let w = if underline == Underline::Thick { thick * 2.0 } else { thick }; - out.push(Draw::Line { x0, y0: uy, x1, y1: uy, width: w, color: ucolor, stroke: stroke_of(underline), alpha }); + out.push(Draw::Line { x0, y0: uy, x1, y1: uy, width: w, color: ucolor, stroke: stroke_of(underline), alpha, text_bg }); } } if struck { let sy = base - st.shift - st.size * 0.28; let stroke = if double { Stroke::Double } else { Stroke::Solid }; - out.push(Draw::Line { x0, y0: sy, x1, y1: sy, width: thick, color, stroke, alpha }); + out.push(Draw::Line { x0, y0: sy, x1, y1: sy, width: thick, color, stroke, alpha, text_bg }); } } } @@ -934,6 +966,7 @@ fn lines( color, stroke: if *grammar { Stroke::Double } else { Stroke::Wave }, alpha, + text_bg: None, }); } } @@ -963,6 +996,7 @@ fn lines( text: String::new(), link: None, ranges: Vec::new(), + text_bg: None, }); } // Inline objects. @@ -1037,6 +1071,7 @@ fn lines( text: r.text.clone(), link: None, ranges, + text_bg: st.rc.highlight.or(st.rc.shading), }); } } @@ -1069,6 +1104,7 @@ fn lines( color: Rgb(0x60, 0x60, 0x60), stroke: Stroke::Dotted, alpha, + text_bg: None, }); out.push(Draw::Mark { x: cx + 1.0, baseline: base, size, ch: label, color: None }); } @@ -1159,7 +1195,7 @@ fn section_break_mark(start: SectionStart, x0: f32, x1: f32, base: f32, size: f3 let tx = (x0 + x1 - w) / 2.0; for (a, b) in [(x0, tx - gap), (tx + w + gap, x1)] { for y in [mid - 1.2, mid + 1.2] { - out.push(Draw::Line { x0: a, y0: y, x1: b, y1: y, width: 0.5, color, stroke: Stroke::Dotted, alpha }); + out.push(Draw::Line { x0: a, y0: y, x1: b, y1: y, width: 0.5, color, stroke: Stroke::Dotted, alpha, text_bg: None }); } } out.push(Draw::MarkText { x: tx, baseline: mid + lsize * 0.33, size: lsize, text: text.into() }); @@ -1181,8 +1217,11 @@ fn equation(items: &[MItem], x: f32, base: f32, alpha: f32, screen: bool, out: & link: None, // Equation text maps back through the font (no per-glyph ranges from the math layout). ranges: Vec::new(), + text_bg: None, }), - MItem::Rect { x: rx, y, w, h, color } => out.push(Draw::Fill { rect: Rect::new(x + rx, base - y - h, *w, *h), color: *color, alpha }), + MItem::Rect { x: rx, y, w, h, color } => { + out.push(Draw::Fill { rect: Rect::new(x + rx, base - y - h, *w, *h), color: *color, alpha, text_bg: false }) + } MItem::Line { x0, y0, x1, y1, width, color, dotted } => out.push(Draw::Line { x0: x + x0, y0: base - y0, @@ -1192,6 +1231,7 @@ fn equation(items: &[MItem], x: f32, base: f32, alpha: f32, screen: bool, out: & color: *color, stroke: if *dotted { Stroke::Dotted } else { Stroke::Solid }, alpha, + text_bg: None, }), MItem::ScreenOnly(inner) => { if screen { @@ -1428,6 +1468,7 @@ fn text_glyphs(doc: &Document, r: Rect, it: &GraphicItem, alpha: f32, k: f32) -> text: text.clone(), link: None, ranges: Vec::new(), + text_bg: None, }) } @@ -1575,13 +1616,13 @@ pub fn upright_in_vertical(c: char) -> bool { fn upright_east_asian(items: Vec) -> Vec { let mut out = Vec::with_capacity(items.len()); for d in items { - let Draw::Glyphs { face, size, glyphs, color, alpha, synth_bold, synth_italic, text, link, ranges } = d else { + let Draw::Glyphs { face, size, glyphs, color, alpha, synth_bold, synth_italic, text, link, ranges, text_bg } = d else { out.push(d); continue; }; let up = |k: usize| ranges.get(k).and_then(|r| text.get(r.clone())).and_then(|t| t.chars().next()).is_some_and(upright_in_vertical); if ranges.len() != glyphs.len() || !(0..glyphs.len()).any(up) { - out.push(Draw::Glyphs { face, size, glyphs, color, alpha, synth_bold, synth_italic, text, link, ranges }); + out.push(Draw::Glyphs { face, size, glyphs, color, alpha, synth_bold, synth_italic, text, link, ranges, text_bg }); continue; } let piece = |k0: usize, k1: usize| -> Draw { @@ -1590,7 +1631,7 @@ fn upright_east_asian(items: Vec) -> Vec { let (b0, b1) = (rs.iter().map(|r| r.start).min().unwrap_or(0), rs.iter().map(|r| r.end).max().unwrap_or(0)); let t = text.get(b0..b1).unwrap_or("").to_string(); let rs = rs.iter().map(|r| r.start.saturating_sub(b0)..r.end.saturating_sub(b0)).collect(); - Draw::Glyphs { face, size, glyphs: gs, color, alpha, synth_bold, synth_italic, text: t, link: link.clone(), ranges: rs } + Draw::Glyphs { face, size, glyphs: gs, color, alpha, synth_bold, synth_italic, text: t, link: link.clone(), ranges: rs, text_bg } }; let mut k0 = 0; while k0 < glyphs.len() { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 7baaad370..0f2a5f830 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -5397,6 +5397,48 @@ fn automatic_spacing_drops_between_list_items() { } } +/// #1252: highlight and character-shading fills are marked as text backgrounds (a dark page dims +/// them), and so is the text drawn on them; a paragraph's shading is an ordinary fill. +#[test] +fn highlight_fill_is_marked_as_a_text_background() { + use wordcraft_doc::props::Highlight; + let cp = wordcraft_doc::CharProps { highlight: Some(Highlight::Yellow), ..Default::default() }; + let mut p = wordcraft_doc::Paragraph::with_text("Marked", cp); + p.props.shading = Some(wordcraft_doc::Rgb(0xDD, 0xEE, 0xFF)); + let plain = wordcraft_doc::Paragraph::with_text("Plain", Default::default()); + let mut d = Document::from_text("x"); + d.body = vec![wordcraft_doc::para_block(p), wordcraft_doc::para_block(plain)]; + let l = lay(&d); + let items = display::page_display(&d, &l.pages[0], &display::DisplayOptions::default()); + let fills: Vec<(wordcraft_doc::Rgb, bool)> = + items.iter().filter_map(|i| if let display::Draw::Fill { color, text_bg, .. } = i { Some((*color, *text_bg)) } else { None }).collect(); + assert!(fills.contains(&(wordcraft_doc::Rgb(255, 255, 0), true)), "highlight is a text background: {fills:?}"); + assert!(fills.contains(&(wordcraft_doc::Rgb(0xDD, 0xEE, 0xFF), false)), "paragraph shading is not: {fills:?}"); + let yellow = Some(wordcraft_doc::Rgb(255, 255, 0)); + let glyphs: Vec<(String, Option)> = items + .iter() + .filter_map(|i| if let display::Draw::Glyphs { text, text_bg, .. } = i { Some((text.clone(), *text_bg)) } else { None }) + .collect(); + assert!(glyphs.iter().any(|(t, bg)| t.contains("Marked") && *bg == yellow), "{glyphs:?}"); + assert!(glyphs.iter().any(|(t, bg)| t.contains("Plain") && bg.is_none()), "{glyphs:?}"); +} + +/// #1252 review: an underline and strikethrough on a highlighted run carry the highlight, like +/// its glyphs; other lines (here a paragraph border) don't. +#[test] +fn underline_on_a_highlight_carries_the_text_background() { + use wordcraft_doc::props::{Highlight, Underline}; + let cp = + wordcraft_doc::CharProps { highlight: Some(Highlight::Teal), underline: Some(Underline::Single), strike: Some(true), ..Default::default() }; + let mut d = Document::from_text("x"); + d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Marked", cp))]; + let l = lay(&d); + let items = display::page_display(&d, &l.pages[0], &display::DisplayOptions::default()); + let lines: Vec> = + items.iter().filter_map(|i| if let display::Draw::Line { text_bg, .. } = i { Some(*text_bg) } else { None }).collect(); + assert_eq!(lines, vec![Some(wordcraft_doc::Rgb(0, 128, 128)); 2], "underline and strikethrough"); +} + /// #1352 helpers: a body paragraph with a PAGE field after each piece of `pieces` (a piece may /// hold a page break), after `before` paragraphs that each start a new page. fn page_field_doc(before: usize, pieces: &[&str]) -> Document { diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 1a818f3c4..f1a14c09f 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -787,13 +787,13 @@ impl Exporter<'_> { self.draw(s, d); } } - Draw::Glyphs { face, size, glyphs, color, alpha, synth_bold, synth_italic, text, link, ranges } => { + Draw::Glyphs { face, size, glyphs, color, alpha, synth_bold, synth_italic, text, link, ranges, .. } => { self.glyphs(s, face, *size, glyphs, *color, *alpha, *synth_bold, *synth_italic, text, ranges); if let Some(l) = link { self.link_rect(face, *size, glyphs, l); } } - Draw::Fill { rect, color, alpha } => { + Draw::Fill { rect, color, alpha, .. } => { if let Some(p) = rect_path(rect) { s.set_stroke(None); s.set_fill(Some(fill(*color, *alpha))); @@ -801,7 +801,7 @@ impl Exporter<'_> { s.set_fill(None); } } - Draw::Line { x0, y0, x1, y1, width, color, stroke, alpha } => self.line(s, (*x0, *y0, *x1, *y1), *width, *color, *stroke, *alpha), + Draw::Line { x0, y0, x1, y1, width, color, stroke, alpha, .. } => self.line(s, (*x0, *y0, *x1, *y1), *width, *color, *stroke, *alpha), Draw::Image { rect, media, crop, alpha } => self.picture(s, rect, media, crop, *alpha), Draw::Path { segs, fill: f, stroke, stroke_width } => { let mut pb = PathBuilder::new(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 8092d632a..48127cd31 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -374,6 +374,50 @@ impl RenderOptions { let lift = |v: u8| (p + v as u32 * (255 - p) / 255).min(255) as u8; Rgb(lift(r), lift(g), lift(b)) } + + /// The on-screen colour for a text background (highlight, character shading). A dark page + /// dims it toward black, keeping its hue, instead of inverting it: inverting leaves bright + /// hues such as yellow bright, so the white text on them can't be read (#1252). Every + /// colour lands dark enough for white text (WCAG contrast ≥ 4.5); yellow turns a dim olive. + pub fn ink_bg(&self, c: Rgb) -> Rgb { + if !self.dark { + return c; + } + let f = |v: u8| (DARK_TEXT_BG_FLOOR as u32 + v as u32 * DARK_TEXT_BG_SPAN as u32 / 255).min(255) as u8; + Rgb(f(c.0), f(c.1), f(c.2)) + } + + /// The on-screen colour for text (or its underline) drawn on the text background `bg`, if + /// any. A dark page dims those backgrounds ([`RenderOptions::ink_bg`]), so the text takes the + /// lighter of its colour and its inverted colour (black, explicit or Automatic, turns white; + /// Automatic white on a dark highlight stays white), and white if that still falls short of + /// 4.5:1 against the dimmed background (pure blue or red on a dim olive). White always + /// reaches it: no dimmed background is lighter than #4A4A4A. + pub fn ink_text(&self, c: Rgb, bg: Option) -> Rgb { + let inked = self.ink(c); + let Some(bg) = bg.filter(|_| self.dark) else { return inked }; + let lighter = if c.luma() > inked.luma() { c } else { inked }; + if contrast(lighter, self.ink_bg(bg)) >= 4.5 { lighter } else { Rgb::WHITE } + } +} + +// The dim assumes the default dark paper grey (`DARK_PAPER`; the app never sets another +// `dark_paper`): the backgrounds stay close to it and under #4A4A4A, so white text on them +// reaches 4.5:1. Revisit both if the paper grey becomes configurable. +/// Darkest channel of a text background on a dark page ([`RenderOptions::ink_bg`]). +const DARK_TEXT_BG_FLOOR: u8 = 0x10; +/// How far a full channel lifts a dark page's text background above the floor. +const DARK_TEXT_BG_SPAN: u8 = 0x3A; + +/// WCAG 2 contrast ratio between two sRGB colours (1 to 21). +fn contrast(a: Rgb, b: Rgb) -> f32 { + let lin = |v: u8| { + let s = v as f32 / 255.0; + if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) } + }; + let lum = |c: Rgb| 0.2126 * lin(c.0) + 0.7152 * lin(c.1) + 0.0722 * lin(c.2); + let (la, lb) = (lum(a), lum(b)); + (la.max(lb) + 0.05) / (la.min(lb) + 0.05) } /// Invert HSL lightness, keeping hue and saturation: shifting every channel by @@ -452,7 +496,7 @@ fn blend_backgrounds(items: &[Draw], p: wordcraft_geom::Point, opts: &RenderOpti } for it in items { let (color, alpha) = match it { - Draw::Fill { rect, color, alpha } if rect.contains(p) => (*color, *alpha), + Draw::Fill { rect, color, alpha, .. } if rect.contains(p) => (*color, *alpha), // The shape's own outline (an ellipse's corners are not inside it). Shape fills are // drawn opaque (the model has no fill transparency), so they cover what's beneath. Draw::Shape { rect, kind, fill: Some(fill), corner, .. } @@ -590,18 +634,18 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl draw(ctx, doc, d, view, visible, opts); } } - Draw::Fill { rect, color: c, alpha } => { + Draw::Fill { rect, color: c, alpha, text_bg } => { let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64); if !r.overlaps(*visible) { return; } ctx.set_transform(view); - ctx.set_paint(color(opts.ink(*c), *alpha)); + ctx.set_paint(color(if *text_bg { opts.ink_bg(*c) } else { opts.ink(*c) }, *alpha)); ctx.fill_rect(&r); } - Draw::Line { x0, y0, x1, y1, width, color: c, stroke, alpha } => { + Draw::Line { x0, y0, x1, y1, width, color: c, stroke, alpha, text_bg } => { ctx.set_transform(view); - ctx.set_paint(color(opts.ink(*c), *alpha)); + ctx.set_paint(color(opts.ink_text(*c, *text_bg), *alpha)); let w = *width as f64; let mut st = kurbo::Stroke::new(w); match stroke { @@ -632,14 +676,14 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl ctx.set_stroke(st); line(ctx, 0.0); } - Draw::Glyphs { face, size, glyphs, color: c, alpha, synth_bold, synth_italic, .. } => { + Draw::Glyphs { face, size, glyphs, color: c, alpha, synth_bold, synth_italic, text_bg, .. } => { // Invisible glyphs (WordArt letters are drawn from their outlines) carry text only. if *alpha <= 0.0 { return; } let db = FontDb::global(); let k = *size as f64 / face.upem.max(1.0); - ctx.set_paint(color(opts.ink(*c), *alpha)); + ctx.set_paint(color(opts.ink_text(*c, *text_bg), *alpha)); let skew = if *synth_italic { Affine::new([1.0, 0.0, -0.21, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; // Outline strokes, in font units: synthetic bold, then on-screen stem darkening (a // stroke grows the outline by half its width on each side). Darkening is skipped for @@ -1254,6 +1298,110 @@ mod tests { assert!(light > 30, "light {light}"); } + /// A document whose only text is a yellow-highlighted run. + fn yellow_highlight_doc() -> (Document, wordcraft_layout::DocLayout) { + let cp = wordcraft_doc::CharProps { highlight: Some(wordcraft_doc::props::Highlight::Yellow), size: Some(24.0), ..Default::default() }; + let mut d = Document::from_text("x"); + d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("HHHH HHHH", cp))]; + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); + (d, l) + } + + /// #1252: on a dark page a highlight is drawn dimmed (keeping its hue) so the white text on it + /// reads; a light page is unchanged. + #[test] + fn dark_page_dims_a_highlight_raster() { + let (d, l) = yellow_highlight_doc(); + let items = page_display(&d, &l.pages[0], &DisplayOptions::default()); + let r = items + .iter() + .find_map(|i| match i { + Draw::Fill { rect, color, .. } if *color == Rgb(255, 255, 0) => Some(*rect), + _ => None, + }) + .unwrap(); + // Just inside the top-left corner of the highlight: above the capitals, so pure background. + let (px, py) = ((r.x + 1.5) as u32, (r.y + 1.5) as u32); + let light = render_page(&d, &l.pages[0], 1.0, &RenderOptions::default()); + assert_eq!(light.pixel(px, py), [255, 255, 0, 255], "light page keeps bright yellow"); + let dark = render_page(&d, &l.pages[0], 1.0, &RenderOptions { dark: true, ..Default::default() }); + let [r8, g8, b8, _] = dark.pixel(px, py); + assert!(r8 < 0x60 && g8 < 0x60, "dark page highlight is dim, got {:?}", (r8, g8, b8)); + assert!(r8.abs_diff(g8) <= 4 && g8 > b8.saturating_add(0x20), "dark page highlight stays yellow in hue, got {:?}", (r8, g8, b8)); + } + + /// #1252: on a dark page every highlight colour is dimmed so the text drawn on it reads + /// (Automatic and explicit black text both draw white); a light page is the identity. + #[test] + fn dark_page_text_backgrounds_keep_text_readable() { + use wordcraft_doc::props::{Highlight, TextColor}; + use wordcraft_layout::display::text_color; + let dark = RenderOptions { dark: true, ..Default::default() }; + let light = RenderOptions::default(); + let colours: Vec = Highlight::ALL.iter().filter_map(|h| h.rgb()).collect(); + assert_eq!(colours.len(), 16); + for c in colours { + let bg = dark.ink_bg(c); + for (what, tc) in [("automatic", text_color(&TextColor::Auto, Some(c))), ("black", Rgb::BLACK)] { + let fg = dark.ink_text(tc, Some(c)); + assert_eq!(fg, Rgb::WHITE, "{what} text on {c:?} draws white"); + let k = contrast(fg, bg); + assert!(k >= 4.5, "{what} text on {c:?}: {fg:?} on {bg:?} has contrast {k:.2}"); + } + assert_eq!(light.ink_bg(c), c, "light page keeps {c:?}"); + assert_eq!(light.ink_text(c, Some(c)), c, "light page keeps text {c:?}"); + } + // Yellow turns a dim olive (~#4a4a10), still yellow in hue. + let Rgb(r, g, b) = dark.ink_bg(Rgb(255, 255, 0)); + assert!(r.abs_diff(0x4A) <= 8 && g.abs_diff(0x4A) <= 8 && b.abs_diff(0x10) <= 8, "yellow → {:?}", (r, g, b)); + assert!(r == g && g > b); + // Text off a highlight keeps the plain inversion. + assert_eq!(dark.ink_text(Rgb::WHITE, None), dark.ink(Rgb::WHITE)); + } + + /// #1252 review: explicit coloured text (pure blue, red, every highlight colour) on every + /// dimmed highlight still reaches 4.5:1 on a dark page. + #[test] + fn dark_page_coloured_text_on_highlights_reads() { + use wordcraft_doc::props::Highlight; + let dark = RenderOptions { dark: true, ..Default::default() }; + let colours: Vec = Highlight::ALL.iter().filter_map(|h| h.rgb()).collect(); + let texts: Vec = [Rgb(0, 0, 255), Rgb(255, 0, 0)].into_iter().chain(colours.iter().copied()).collect(); + for &h in &colours { + let bg = dark.ink_bg(h); + for &tc in &texts { + let fg = dark.ink_text(tc, Some(h)); + let k = contrast(fg, bg); + assert!(k >= 4.5, "text {tc:?} on {h:?}: {fg:?} on {bg:?} has contrast {k:.2}"); + } + } + } + + /// #1252 review: an Automatic underline on a dark highlight (black) draws light on a dark page, + /// like the text it underlines, not the dark grey plain inversion gives white. + #[test] + fn dark_page_underline_on_a_dark_highlight_reads() { + use wordcraft_doc::props::{Highlight, Underline}; + let cp = + wordcraft_doc::CharProps { highlight: Some(Highlight::Black), underline: Some(Underline::Thick), size: Some(24.0), ..Default::default() }; + let mut d = Document::from_text("x"); + d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("HHHH HHHH", cp))]; + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); + let items = page_display(&d, &l.pages[0], &DisplayOptions::default()); + let (x0, x1, y, w) = items + .iter() + .find_map(|i| match i { + Draw::Line { x0, x1, y0, width, .. } => Some((*x0, *x1, *y0, *width)), + _ => None, + }) + .unwrap(); + assert!(w >= 1.5, "thick underline {w}"); + let (px, py) = (((x0 + x1) / 2.0) as u32, y.round() as u32); + let dark = render_page(&d, &l.pages[0], 4.0, &RenderOptions { dark: true, ..Default::default() }); + let [r8, g8, b8, _] = dark.pixel(px * 4, py * 4); + assert!(r8 > 0xC0 && g8 > 0xC0 && b8 > 0xC0, "underline draws light, got {:?}", (r8, g8, b8)); + } + /// One EMF record: type, size, payload (a multiple of 4 bytes). fn emf_rec(typ: u32, payload: &[u8]) -> Vec { let mut v = typ.to_le_bytes().to_vec(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 70ac8068e..3e7b01f17 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-14 · **Change:** trivial (Page Setup paper size and Layout › Size › More Paper Sizes…, #1433; previously trivial: full screen on F11, #980; previously trivial: File dialogs row: Save As lists each format by name, #1399; 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-14 · **Change:** trivial (Page Setup paper size and Layout › Size › More Paper Sizes…, #1433; previously trivial: dark page dims highlights, #1252; previously trivial: full screen on F11, #980; previously trivial: File dialogs row: Save As lists each format by name, #1399; 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 @@ -35,6 +35,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 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 | 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% | — | | Caret motion: keys and typing glide the caret to its new place (100 ms, `cubic-bezier(0, 0, 0, 1)`), the blink fades (530 ms on and off, 100 ms fades); clicks and scripted moves don't glide. File › Options › Advanced › Animation (`ui.caretAnimation`) turns it off. The caret is black or white by contrast with what lies under it (paper, page colour, dark page, shading, highlight), never by the UI theme (#299) | ✅ as measured from Word for Windows (`caret_glide.rs`); zoom levels other than 100%, modifier keys and off-screen moves not measured | ✅ on Windows; Word for Mac not measured | 90% | 1–2 | +| Interface themes (light, dark, system #249, follows OS changes live #311), dark page separate from the interface theme (#194, #312); on a dark page highlights and character shading are dimmed, keeping their hue, so the text on them stays readable; coloured text below 4.5:1 on them is drawn white, and underlines and strikethroughs follow the same rule (#1252) | ✅ | ✅ | 90% | — | | Interface themes (light, dark, system #249, follows OS changes live #311), dark page separate from the interface theme (#194, #312) | ✅ | ✅ | 90% | — | | Interface size (File › Options › General: Auto, which keeps the system display scale and shows the size it draws at, or an absolute 75–300 %, so "100%" is one screen pixel per point; `ui.interfaceSize`) (#475, #822). A size from before #822 (on top of the display scale) becomes the absolute size it drew at. On the web the browser's zoom is part of the display scale, so a chosen size cancels browser zoom (Ctrl+plus/minus no longer resize the interface); Auto follows it | ✅ | system display scaling only | 100% | — | | Animation effects (File › Options › Accessibility: Use system setting, On, Off; `ui.animations`). Off: a wheel notch scrolls at once; Ctrl+wheel zooms in whole steps, each drawn finished in its own frame (a notch at once; touchpad pinches and high-resolution wheels in steps of about 10% rather than gliding); menus, buttons and the scroll to the caret neither fade nor glide | ✅ "Use system setting" reads Windows (Animation effects), macOS (Reduce motion), the Linux/BSD desktops (settings portal: the cross-desktop reduced-motion preference, GNOME's switch, KDE's animation speed; the GTK and KDE settings files where no portal answers) and browsers (`prefers-reduced-motion`), and follows a change while running. Run on Windows and against a stand-in portal on Linux; not yet tried on a Mac or a real GNOME or KDE session | not compared | 90% | 0.5–1 | @@ -53,6 +54,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-13 | trivial | Interface size: a chosen size is absolute ("100%" is one screen pixel per point, even where the system reports 200 % under a bare window manager) and the new default, Auto, keeps the display scale and shows the size it draws at; 75–300 %; an old saved size becomes the size it drew at; on the web a chosen size cancels browser zoom, Auto follows it (#822) | | 2026-10-13 | trivial | Copy and Cut of a selection holding an equation put HTML with the equation as MathML on the system clipboard beside the text (desktop, through arboard), as MathML in HTML on the clipboard (Word/LibreOffice paste not yet verified; OMML is #1297) (#816). Only copies containing an equation get HTML. Web: text only | | 2026-10-14 | trivial | Page Setup: a Paper group with Paper size (the named sizes, the one the section has in either orientation, or Custom size) and Width and Height in the measurement unit; a wider-than-tall size turns the orientation to landscape; a size the margins don't fit keeps the dialog open with a message and applies nothing. Layout › Size ends with More Paper Sizes…, which opens it (#1433) | +| 2026-10-13 | trivial | Dark page (View › Switch Modes): highlights and character shading are dimmed toward black, keeping their hue (yellow turns a dim olive), instead of having their lightness inverted, which left yellow bright under white text; text on them draws light (white for black and Automatic), coloured text below 4.5:1 on them is drawn white, and underlines and strikethroughs follow the same rule. Light page, print and exports unchanged. Unlike Word, Automatic text stays white on the dark page (#1252) | | 2026-10-13 | trivial | Full screen (#980): View › Window › Full Screen and F11 (⌃⌘F on macOS), Escape leaves it once nothing else takes it (a selection collapses first); Next Field moves to Alt+F1; keyboard counts re-measured (152 commands, 172 combinations) | | 2026-10-14 | trivial | File dialogs: the system Save As type list names each format, the document's own first (#1399) | | 2026-10-11 | trivial | Tables on the page: Ctrl shares a column border's change among the columns right of it, the left edge moves a left-aligned table's indent (a centred or right-aligned one keeps its alignment), a border of cells selected across moves only in their rows, Alt shows the column widths on the ruler (#711); 80% → 90% |