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